diff --git a/README.md b/README.md index d085f23..12bda9d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ by removing interactive controls and making content fully visible. (Closes #708) | Path | Description | | ------------------- | ----------------------------------------- | | `/` | Landing page | +| `/login` | Sign-in page with polite aria-live status announcements (issue #530) | | `/dashboard` | Developer dashboard | | `/marketplace` | API marketplace | | `/billing` | USDC deposit and settlements | diff --git a/docs/LoginPage-aria-live.md b/docs/LoginPage-aria-live.md new file mode 100644 index 0000000..70fbdf3 --- /dev/null +++ b/docs/LoginPage-aria-live.md @@ -0,0 +1,36 @@ +/** + * LoginPage aria-live status announcements — issue #530 (GrantFox FWC26). + * + * Screen-reader-friendly announcement of LoginPage status changes via a + * polite aria-live region (`role="status"`, `aria-live="polite"`). + */ + +## Summary + +`LoginPage` announces submitting, success, and error status changes through +the shared `LiveRegion` component so assistive technology users hear feedback +without depending on the visible status banner alone. + +## Files changed + +| File | Change | +|---|---| +| `src/pages/LoginPage.tsx` | New mock sign-in page with polite `aria-live` status announcements | +| `src/pages/LoginPage.test.tsx` | Focused tests for live-region announcements and form status flow | +| `src/App.tsx` | Registers `/login` route and document title/description | +| `README.md` | Documents the `/login` route | + +## API / visible changes + +- **New route:** `/login` — mock email/password sign-in (no backend auth) +- No network/API contract changes +- Visible status banner mirrors live-region messages for sighted users +- On success, navigates to `/dashboard` after a short delay + +## Accessibility + +- Uses `LiveRegion` with `aria-live="polite"`, `role="status"`, `aria-atomic="true"` +- Announces: validation prompts, submitting, success, and failure (with detail) +- Submit control exposes `aria-busy` while in flight +- Focus moves to the status banner after submit for keyboard users +- Labels, `aria-invalid`, and `aria-describedby` on form fields diff --git a/src/App.tsx b/src/App.tsx index 9f4c9a3..c7f0b11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { ShortcutsModal } from "./components/ShortcutsModal"; import { ToastProvider } from "./components/Toast"; import { InvoiceCard } from "./pages/InvoiceCard"; import BillingHistory from "./pages/BillingHistory"; +import LoginPage from "./pages/LoginPage"; type DepositStage = "input" | "approving" | "pending" | "confirmed" | "failed"; type DemoOutcome = "confirmed" | "failed"; @@ -127,6 +128,7 @@ const APP_ROUTES = { serverError: "/500", rateLimitCard: "/rate-limit", slaCard: "/marketplace/grantfox-wave-compute/sla", + login: "/login", } as const; function createMockHash() { @@ -285,6 +287,7 @@ function App() { "/api-usage": "API Usage – Callora", [APP_ROUTES.landing]: "Callora", [APP_ROUTES.endpointSummary]: "Endpoint Summary – Callora", + [APP_ROUTES.login]: "Sign in – Callora", }; const routeDescriptionMap: Record = { [APP_ROUTES.marketplace]: "Explore APIs on the Callora marketplace, discover and integrate APIs for your applications.", @@ -294,6 +297,7 @@ function App() { "/api-usage": "Monitor API usage, request stats, and view call history.", [APP_ROUTES.landing]: "Callora - Programmable API Access, pay-per-call billing, and on-chain settlement.", [APP_ROUTES.endpointSummary]: "Quick reference list of all API endpoints on Callora.", + [APP_ROUTES.login]: "Sign in to Callora to manage API usage, deposits, and marketplace listings.", }; const currentTitle = routeTitleMap[location.pathname] ?? "Callora"; const currentDescription = routeDescriptionMap[location.pathname]; @@ -581,6 +585,8 @@ function App() { element={ navigate(APP_ROUTES.marketplace)} onPublishApi={() => navigate(APP_ROUTES.publish)} />} /> + } /> + } /> } /> diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..3d289d0 --- /dev/null +++ b/src/pages/LoginPage.test.tsx @@ -0,0 +1,197 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LoginPage from "./LoginPage"; + +const mockNavigate = vi.fn(); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +vi.mock("../hooks/useDocumentTitle", () => ({ + default: vi.fn(), +})); + +function renderPage(props?: Partial>) { + return render( + + + , + ); +} + +function getLiveRegion(): HTMLElement { + return screen.getByTestId("live-region-login-status"); +} + +describe("LoginPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + describe("rendering", () => { + it("renders the sign-in heading and form fields", () => { + renderPage(); + expect(screen.getByRole("heading", { level: 1, name: /Sign in/i })).toBeTruthy(); + expect(screen.getByLabelText(/^Email$/i)).toBeTruthy(); + expect(screen.getByLabelText(/^Password$/i)).toBeTruthy(); + expect(screen.getByTestId("login-submit")).toBeTruthy(); + }); + + it("renders a polite aria-live region for status updates", () => { + renderPage(); + const region = getLiveRegion(); + expect(region.getAttribute("aria-live")).toBe("polite"); + expect(region.getAttribute("role")).toBe("status"); + expect(region.getAttribute("aria-atomic")).toBe("true"); + }); + }); + + describe("aria-live status announcements", () => { + it("announces validation when credentials are empty", async () => { + renderPage({ + onSubmit: vi.fn(() => Promise.resolve()), + }); + + fireEvent.click(screen.getByTestId("login-submit")); + + await waitFor(() => { + expect(getLiveRegion().textContent).toMatch(/Enter both email and password/i); + }); + expect(screen.getByRole("alert")).toBeTruthy(); + }); + + it("announces submitting then success on a successful sign-in", async () => { + let resolveSignIn!: () => void; + const onSubmit = vi.fn( + () => + new Promise((resolve) => { + resolveSignIn = resolve; + }), + ); + + renderPage({ onSubmit }); + + fireEvent.change(screen.getByLabelText(/^Email$/i), { + target: { value: "dev@callora.dev" }, + }); + fireEvent.change(screen.getByLabelText(/^Password$/i), { + target: { value: "secret" }, + }); + fireEvent.click(screen.getByTestId("login-submit")); + + await waitFor(() => { + expect(getLiveRegion().textContent).toMatch(/Signing in/i); + }); + + expect(screen.getByTestId("login-status-banner").getAttribute("data-status")).toBe( + "submitting", + ); + expect(screen.getByTestId("login-submit").getAttribute("aria-busy")).toBe("true"); + + resolveSignIn(); + + await waitFor(() => { + expect(getLiveRegion().textContent).toMatch(/Sign-in successful/i); + }); + expect(screen.getByTestId("login-status-banner").getAttribute("data-status")).toBe( + "success", + ); + }); + + it("announces failure details when sign-in rejects", async () => { + const onSubmit = vi.fn(() => + Promise.reject(new Error("Invalid credentials")), + ); + + renderPage({ onSubmit }); + + fireEvent.change(screen.getByLabelText(/^Email$/i), { + target: { value: "dev@callora.dev" }, + }); + fireEvent.change(screen.getByLabelText(/^Password$/i), { + target: { value: "wrong" }, + }); + fireEvent.click(screen.getByTestId("login-submit")); + + await waitFor(() => { + expect(getLiveRegion().textContent).toMatch(/Sign-in failed/i); + }); + expect(getLiveRegion().textContent).toMatch(/Invalid credentials/i); + expect(screen.getByTestId("login-status-banner").getAttribute("data-status")).toBe( + "error", + ); + expect(screen.getByText("Invalid credentials")).toBeTruthy(); + }); + }); + + describe("form behavior", () => { + it("disables the submit button while submitting", async () => { + const onSubmit = vi.fn(() => new Promise(() => {})); + renderPage({ onSubmit }); + + fireEvent.change(screen.getByLabelText(/^Email$/i), { + target: { value: "dev@callora.dev" }, + }); + fireEvent.change(screen.getByLabelText(/^Password$/i), { + target: { value: "secret" }, + }); + fireEvent.click(screen.getByTestId("login-submit")); + + await waitFor(() => { + expect(screen.getByTestId("login-submit")).toBeDisabled(); + }); + }); + + it("calls onSubmit with trimmed email and password", async () => { + const onSubmit = vi.fn(() => Promise.resolve()); + renderPage({ onSubmit }); + + fireEvent.change(screen.getByLabelText(/^Email$/i), { + target: { value: " dev@callora.dev " }, + }); + fireEvent.change(screen.getByLabelText(/^Password$/i), { + target: { value: "secret" }, + }); + fireEvent.click(screen.getByTestId("login-submit")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith("dev@callora.dev", "secret"); + }); + }); + + it("navigates to the success redirect after a successful sign-in", async () => { + const onSubmit = vi.fn(() => Promise.resolve()); + renderPage({ onSubmit, successRedirect: "/dashboard" }); + + fireEvent.change(screen.getByLabelText(/^Email$/i), { + target: { value: "dev@callora.dev" }, + }); + fireEvent.change(screen.getByLabelText(/^Password$/i), { + target: { value: "secret" }, + }); + fireEvent.click(screen.getByTestId("login-submit")); + + await waitFor(() => { + expect(getLiveRegion().textContent).toMatch(/Sign-in successful/i); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/dashboard"); + }); + }); + }); +}); diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..50d66bc --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,308 @@ +import { FormEvent, useCallback, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import LiveRegion from "../components/LiveRegion"; +import useDocumentTitle from "../hooks/useDocumentTitle"; + +/** + * LoginPage — issue #530 (GrantFox FWC26 / Stellar Wave campaign). + * + * Mock sign-in surface that announces every status change through a polite + * `aria-live` region so screen-reader users hear submitting / success / error + * updates without relying on visual cues alone (WCAG 2.1 AA). + * + * Accessibility: + * - Single `role="status"` live region via shared `LiveRegion` (`aria-live="polite"`). + * - Form fields use associated labels; validation errors are linked with `aria-describedby`. + * - Submit button exposes busy state via `aria-busy` / `disabled` while in flight. + * - Focus moves to the visible status banner after submit so keyboard users land on feedback. + * + * Design-token + dark-mode: + * - Colors and borders use CSS custom properties (`--surface`, `--text`, `--accent`, …). + * - No hardcoded hex values; ThemeProvider light/dark tokens apply automatically. + */ + +export type LoginStatus = "idle" | "submitting" | "success" | "error"; + +export interface LoginPageProps { + /** + * Async sign-in handler. Resolves on success; rejects on failure. + * Defaults to a short mock delay that succeeds for any non-empty credentials. + */ + onSubmit?: (email: string, password: string) => Promise; + /** Destination after a successful sign-in. Defaults to `/dashboard`. */ + successRedirect?: string; +} + +const STATUS_ANNOUNCEMENTS: Record = { + idle: "", + submitting: "Signing in. Please wait.", + success: "Sign-in successful. Redirecting to your dashboard.", + error: "Sign-in failed. Check your email and password, then try again.", +}; + +const STATUS_BANNER: Record, string> = { + submitting: "Signing in…", + success: "Signed in successfully", + error: "Sign-in failed", +}; + +async function defaultSignIn(email: string, password: string): Promise { + await new Promise((resolve) => window.setTimeout(resolve, 400)); + if (!email.trim() || !password.trim()) { + throw new Error("Email and password are required."); + } +} + +export default function LoginPage({ + onSubmit = defaultSignIn, + successRedirect = "/dashboard", +}: LoginPageProps) { + const navigate = useNavigate(); + useDocumentTitle( + "Sign in – Callora", + "Sign in to Callora to manage API usage, deposits, and marketplace listings.", + ); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [status, setStatus] = useState("idle"); + const [errorDetail, setErrorDetail] = useState(""); + const [fieldError, setFieldError] = useState(""); + const [announcement, setAnnouncement] = useState(""); + + const statusBannerRef = useRef(null); + const submittingRef = useRef(false); + + const announce = useCallback((next: LoginStatus, detail = "") => { + const base = STATUS_ANNOUNCEMENTS[next]; + setAnnouncement(detail ? `${base} ${detail}`.trim() : base); + }, []); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (submittingRef.current) return; + + const trimmedEmail = email.trim(); + if (!trimmedEmail || !password) { + setFieldError("Enter both email and password to continue."); + setStatus("idle"); + setAnnouncement("Enter both email and password to continue."); + return; + } + + submittingRef.current = true; + setFieldError(""); + setErrorDetail(""); + setStatus("submitting"); + announce("submitting"); + + // Move focus to the status banner so keyboard / SR users hear the update. + requestAnimationFrame(() => { + statusBannerRef.current?.focus(); + }); + + try { + await onSubmit(trimmedEmail, password); + setStatus("success"); + announce("success"); + window.setTimeout(() => { + navigate(successRedirect); + }, 600); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Something went wrong. Please try again."; + setErrorDetail(message); + setStatus("error"); + announce("error", message); + } finally { + submittingRef.current = false; + } + }; + + const isBusy = status === "submitting"; + const showBanner = status !== "idle"; + + return ( +
+
+

Callora account

+

+ Sign in +

+

+ Access your vault, marketplace listings, and API usage with your + Callora credentials. +

+
+ +
+

+ Account credentials +

+ + {showBanner && ( +
+ {STATUS_BANNER[status as Exclude]} + {status === "error" && errorDetail && ( +

+ {errorDetail} +

+ )} +
+ )} + +
+ + { + setEmail(event.target.value); + if (fieldError) setFieldError(""); + }} + disabled={isBusy || status === "success"} + aria-invalid={Boolean(fieldError)} + aria-describedby={ + fieldError ? "login-field-error" : "login-email-help" + } + style={{ + width: "100%", + marginBottom: "4px", + padding: "10px 12px", + borderRadius: "10px", + border: "1px solid var(--line)", + background: "var(--surface-soft)", + color: "var(--text)", + boxSizing: "border-box", + }} + /> +

+ Use the email associated with your Callora account. +

+ + + { + setPassword(event.target.value); + if (fieldError) setFieldError(""); + }} + disabled={isBusy || status === "success"} + aria-invalid={Boolean(fieldError)} + aria-describedby={fieldError ? "login-field-error" : undefined} + style={{ + width: "100%", + marginBottom: fieldError ? "8px" : "20px", + padding: "10px 12px", + borderRadius: "10px", + border: "1px solid var(--line)", + background: "var(--surface-soft)", + color: "var(--text)", + boxSizing: "border-box", + }} + /> + + {fieldError && ( + + )} + + +
+
+ + {/* Screen-reader status announcements (polite aria-live) */} + +
+ ); +}