[codex] Improve login accessibility and password-manager metadata (#7660)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Human operators sign in through the main auth page and through invite acceptance flows. > - Those forms need to be understandable to assistive technology and password managers. > - The login fields did not consistently expose stable names, ids, autocomplete hints, required state, and error relationships. > - That made it easier for password managers such as 1Password to miss the username/password pairing and harder for screen readers to associate validation errors. > - This pull request tightens the auth and invite form metadata while keeping the visible flow unchanged. > - The benefit is a smoother login and invite-acceptance experience without changing server-side auth behavior. ## Linked Issues or Issue Description No matching GitHub issue found after searching `login accessibility 1Password username password` in `paperclipai/paperclip` issues and PRs. ## What happened? Login and invite auth fields were missing password-manager and accessibility metadata, including stable field identifiers, autocomplete hints, required semantics, and error-region relationships. That made it easier for password managers such as 1Password to miss the username/password pairing and harder for screen readers to associate validation errors. ## Expected behavior Auth fields should be discoverable as username/password fields, distinguish sign-in and sign-up password autocomplete behavior, and expose validation errors through an alert region referenced by invalid inputs. ## Steps to reproduce 1. Open the main `/auth` form or an invite auth form. 2. Inspect the email, password, and sign-up name field attributes. 3. Trigger a validation/auth error and inspect whether invalid inputs reference the displayed error text. ## Paperclip version or commit Prior to this PR on `master`. ## Deployment mode Board UI, all deployments using these forms. ## What Changed - Added stable `id`, `name`, `required`, `aria-required`, `aria-invalid`, `aria-describedby`, and autocomplete metadata to the main auth form. - Added invite auth field metadata so invite sign-up uses `new-password`, invite sign-in uses `current-password`, and the email field is recognized as `username`. - Added alert regions for auth and invite auth errors so invalid fields can reference the displayed error text. - Added focused Vitest coverage for the main auth form and invite auth flow metadata/error behavior. ## Verification - `pnpm exec vitest run ui/src/pages/Auth.test.tsx ui/src/pages/InviteLanding.test.tsx` passes: 2 files, 16 tests. - `pnpm build` passes locally. - PR CI is green on head `3531d1900`, including Build, Canary Dry Run, Typecheck + Release Registry, e2e, general/serialized test shards, policy, commitperclip review, security checks, and aggregate `verify`. - Greptile Review is passing on head `3531d1900`; the P2 alert/live-region review thread was fixed and resolved. - Rebasing onto `public-gh/master` completed cleanly; current base ref is `e50666e4c`. - Confirmed the branch diff does not include `pnpm-lock.yaml`, `.github/workflows`, migrations, or design/image assets. - No screenshots attached: this is a form metadata/accessibility change, and the task specifically asked not to add design screenshots or images unless they are part of the work. ## Risks Low risk. The change is limited to UI form attributes and error wiring. Main risk is password-manager/browser interpretation differences, covered by asserting the emitted DOM metadata rather than a specific vendor integration. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-based coding model with repository tool use and shell execution. Exact hosted model ID/context window are not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuthPage } from "./Auth";
|
||||
|
||||
const getSessionMock = vi.hoisted(() => vi.fn());
|
||||
const signInEmailMock = vi.hoisted(() => vi.fn());
|
||||
const signUpEmailMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/auth", () => ({
|
||||
authApi: {
|
||||
getSession: () => getSessionMock(),
|
||||
signInEmail: (input: unknown) => signInEmailMock(input),
|
||||
signUpEmail: (input: unknown) => signUpEmailMock(input),
|
||||
},
|
||||
}));
|
||||
|
||||
// The ASCII art animation drives a canvas/requestAnimationFrame loop that adds
|
||||
// nothing to these assertions, so stub it out.
|
||||
vi.mock("@/components/AsciiArtAnimation", () => ({
|
||||
AsciiArtAnimation: () => null,
|
||||
}));
|
||||
|
||||
// The router's navigate wrapper reads the active company prefix from context.
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompany: null,
|
||||
selectedCompanyId: null,
|
||||
companies: [],
|
||||
selectionSource: "manual",
|
||||
loading: false,
|
||||
error: null,
|
||||
setSelectedCompanyId: vi.fn(),
|
||||
reloadCompanies: vi.fn(),
|
||||
createCompany: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
function renderAuthPage(container: HTMLElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return { root, queryClient };
|
||||
}
|
||||
|
||||
describe("AuthPage", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
getSessionMock.mockResolvedValue(null);
|
||||
signInEmailMock.mockResolvedValue(undefined);
|
||||
signUpEmailMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function mount() {
|
||||
const { root, queryClient } = renderAuthPage(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/auth"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
it("exposes password-manager metadata and a11y attributes on the sign-in form", async () => {
|
||||
const root = await mount();
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
|
||||
|
||||
expect(emailInput).not.toBeNull();
|
||||
expect(passwordInput).not.toBeNull();
|
||||
|
||||
// 1Password / password-manager recognition: identifier field is "username".
|
||||
expect(emailInput.getAttribute("autocomplete")).toBe("username");
|
||||
expect(emailInput.getAttribute("type")).toBe("email");
|
||||
expect(passwordInput.getAttribute("autocomplete")).toBe("current-password");
|
||||
|
||||
// Stable ids/names for both inputs.
|
||||
expect(emailInput.id).toBe("email");
|
||||
expect(passwordInput.id).toBe("password");
|
||||
|
||||
// Required + programmatic required state.
|
||||
expect(emailInput.required).toBe(true);
|
||||
expect(emailInput.getAttribute("aria-required")).toBe("true");
|
||||
expect(passwordInput.required).toBe(true);
|
||||
expect(passwordInput.getAttribute("aria-required")).toBe("true");
|
||||
|
||||
// Programmatic labels.
|
||||
expect(container.querySelector('label[for="email"]')).not.toBeNull();
|
||||
expect(container.querySelector('label[for="password"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses new-password autocomplete in sign-up mode", async () => {
|
||||
const root = await mount();
|
||||
|
||||
const createOne = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Create one",
|
||||
);
|
||||
expect(createOne).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
createOne?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
|
||||
expect(nameInput).not.toBeNull();
|
||||
expect(nameInput.getAttribute("autocomplete")).toBe("name");
|
||||
expect(nameInput.required).toBe(true);
|
||||
expect(passwordInput.getAttribute("autocomplete")).toBe("new-password");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders auth errors in an assertive alert region referenced by the inputs", async () => {
|
||||
const root = await mount();
|
||||
|
||||
const inputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
inputValueSetter!.call(emailInput, "jane@example.com");
|
||||
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputValueSetter!.call(passwordInput, "wrongpass");
|
||||
passwordInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
signInEmailMock.mockRejectedValueOnce(new Error("Invalid email or password"));
|
||||
|
||||
const form = container.querySelector("form") as HTMLFormElement;
|
||||
await act(async () => {
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]') as HTMLElement;
|
||||
expect(alert).not.toBeNull();
|
||||
expect(alert.hasAttribute("aria-live")).toBe(false);
|
||||
expect(alert.textContent).toContain("Invalid email or password");
|
||||
|
||||
const errorId = alert.id;
|
||||
expect(errorId.length).toBeGreaterThan(0);
|
||||
expect(emailInput.getAttribute("aria-describedby")).toBe(errorId);
|
||||
expect(emailInput.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(passwordInput.getAttribute("aria-describedby")).toBe(errorId);
|
||||
expect(passwordInput.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
+19
-2
@@ -19,6 +19,7 @@ export function AuthPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorId = "auth-error";
|
||||
|
||||
const nextPath = useMemo(
|
||||
() => searchParams.get("next") || getRememberedInvitePath() || "/",
|
||||
@@ -115,6 +116,10 @@ export function AuthPage() {
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="name"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -128,7 +133,11 @@ export function AuthPage() {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
autoFocus={mode === "sign_in"}
|
||||
/>
|
||||
</div>
|
||||
@@ -142,9 +151,17 @@ export function AuthPage() {
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete={mode === "sign_in" ? "current-password" : "new-password"}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
@@ -61,11 +61,20 @@ vi.mock("@/context/CompanyContext", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
describe("InviteLandingPage", () => {
|
||||
@@ -205,6 +214,126 @@ describe("InviteLandingPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("carries password-manager metadata and a11y attributes on the invite auth form", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement;
|
||||
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
|
||||
expect(nameInput).not.toBeNull();
|
||||
expect(emailInput).not.toBeNull();
|
||||
expect(passwordInput).not.toBeNull();
|
||||
|
||||
// Default invite mode is sign-up.
|
||||
expect(emailInput.getAttribute("autocomplete")).toBe("username");
|
||||
expect(emailInput.getAttribute("type")).toBe("email");
|
||||
expect(passwordInput.getAttribute("autocomplete")).toBe("new-password");
|
||||
expect(nameInput.getAttribute("autocomplete")).toBe("name");
|
||||
|
||||
// Namespaced stable ids.
|
||||
expect(emailInput.id).toBe("invite-email");
|
||||
expect(passwordInput.id).toBe("invite-password");
|
||||
expect(nameInput.id).toBe("invite-name");
|
||||
|
||||
// Required + programmatic required state.
|
||||
expect(emailInput.required).toBe(true);
|
||||
expect(emailInput.getAttribute("aria-required")).toBe("true");
|
||||
expect(passwordInput.required).toBe(true);
|
||||
expect(passwordInput.getAttribute("aria-required")).toBe("true");
|
||||
expect(nameInput.required).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders invite auth errors in an alert region referenced by the inputs", async () => {
|
||||
signInEmailMock.mockRejectedValue(
|
||||
Object.assign(new Error("Invalid email or password"), {
|
||||
code: "INVALID_EMAIL_OR_PASSWORD",
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
|
||||
const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "I already have an account",
|
||||
);
|
||||
await act(async () => {
|
||||
existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
|
||||
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
inputValueSetter!.call(emailInput, "jane@example.com");
|
||||
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputValueSetter!.call(passwordInput, "wrongpass");
|
||||
passwordInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement;
|
||||
await act(async () => {
|
||||
authForm.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]') as HTMLElement;
|
||||
expect(alert).not.toBeNull();
|
||||
expect(alert.hasAttribute("aria-live")).toBe(false);
|
||||
|
||||
const errorId = alert.id;
|
||||
expect(errorId.length).toBeGreaterThan(0);
|
||||
expect(emailInput.getAttribute("aria-describedby")).toBe(errorId);
|
||||
expect(emailInput.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(passwordInput.getAttribute("aria-describedby")).toBe(errorId);
|
||||
expect(passwordInput.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("turns invalid sign-in responses into a clear invite-specific message", async () => {
|
||||
signInEmailMock.mockRejectedValue(
|
||||
Object.assign(new Error("Invalid email or password"), {
|
||||
|
||||
@@ -229,6 +229,7 @@ export function InviteLandingPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authFeedback, setAuthFeedback] = useState<AuthFeedback | null>(null);
|
||||
const [autoAcceptStarted, setAutoAcceptStarted] = useState(false);
|
||||
const authErrorId = "invite-auth-error";
|
||||
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
@@ -706,9 +707,10 @@ export function InviteLandingPage() {
|
||||
data-testid="invite-inline-auth"
|
||||
>
|
||||
{authMode === "sign_up" ? (
|
||||
<label className="block text-sm">
|
||||
<label className="block text-sm" htmlFor="invite-name">
|
||||
<span className="mb-1 block text-zinc-400">Name</span>
|
||||
<input
|
||||
id="invite-name"
|
||||
name="name"
|
||||
className={fieldClassName}
|
||||
value={name}
|
||||
@@ -717,13 +719,18 @@ export function InviteLandingPage() {
|
||||
setAuthFeedback(null);
|
||||
}}
|
||||
autoComplete="name"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={authFeedback?.tone === "error" ? true : undefined}
|
||||
aria-describedby={authFeedback ? authErrorId : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<label className="block text-sm" htmlFor="invite-email">
|
||||
<span className="mb-1 block text-zinc-400">Email</span>
|
||||
<input
|
||||
id="invite-email"
|
||||
name="email"
|
||||
type="email"
|
||||
className={fieldClassName}
|
||||
@@ -732,13 +739,18 @@ export function InviteLandingPage() {
|
||||
setEmail(event.target.value);
|
||||
setAuthFeedback(null);
|
||||
}}
|
||||
autoComplete="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={authFeedback?.tone === "error" ? true : undefined}
|
||||
aria-describedby={authFeedback ? authErrorId : undefined}
|
||||
autoFocus={authMode === "sign_in"}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<label className="block text-sm" htmlFor="invite-password">
|
||||
<span className="mb-1 block text-zinc-400">Password</span>
|
||||
<input
|
||||
id="invite-password"
|
||||
name="password"
|
||||
type="password"
|
||||
className={fieldClassName}
|
||||
@@ -748,10 +760,16 @@ export function InviteLandingPage() {
|
||||
setAuthFeedback(null);
|
||||
}}
|
||||
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={authFeedback?.tone === "error" ? true : undefined}
|
||||
aria-describedby={authFeedback ? authErrorId : undefined}
|
||||
/>
|
||||
</label>
|
||||
{authFeedback ? (
|
||||
<p
|
||||
id={authErrorId}
|
||||
role="alert"
|
||||
className={`text-xs ${
|
||||
authFeedback.tone === "info" ? "text-amber-300" : "text-red-400"
|
||||
}`}
|
||||
|
||||
Reference in New Issue
Block a user