diff --git a/ui-react/apps/console/src/components/common/Alert.tsx b/ui-react/apps/console/src/components/common/Alert.tsx new file mode 100644 index 00000000000..e023d0be18b --- /dev/null +++ b/ui-react/apps/console/src/components/common/Alert.tsx @@ -0,0 +1,103 @@ +import { ReactNode } from "react"; +import { + ExclamationCircleIcon, + CheckCircleIcon, + ExclamationTriangleIcon, + InformationCircleIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; + +export type AlertVariant = "error" | "success" | "warning" | "info"; + +interface AlertProps { + /** Visual and semantic variant. Controls color, icon, role, and aria-live. */ + variant: AlertVariant; + + /** Alert body — plain string or rich JSX. */ + children: ReactNode; + + /** When provided, renders a dismiss button (XMark). The parent owns visibility state. */ + onDismiss?: () => void; + + /** Layout overrides only — margins, display mode. Don't override color or typography. */ + className?: string; +} + +type IconComponent = (props: { className?: string; strokeWidth?: number }) => React.ReactElement | null; + +type VariantConfig = { + bg: string; + border: string; + text: string; + Icon: IconComponent; + role: "alert" | "status"; + ariaLive: "assertive" | "polite"; +}; + +const VARIANT: Record = { + error: { + bg: "bg-accent-red/8", + border: "border-accent-red/20", + text: "text-accent-red", + Icon: ExclamationCircleIcon as IconComponent, + role: "alert", + ariaLive: "assertive", + }, + success: { + bg: "bg-accent-green/8", + border: "border-accent-green/20", + text: "text-accent-green", + Icon: CheckCircleIcon as IconComponent, + role: "status", + ariaLive: "polite", + }, + warning: { + bg: "bg-accent-yellow/8", + border: "border-accent-yellow/20", + text: "text-accent-yellow", + Icon: ExclamationTriangleIcon as IconComponent, + role: "alert", + ariaLive: "assertive", + }, + info: { + bg: "bg-accent-blue/8", + border: "border-accent-blue/20", + text: "text-accent-blue", + Icon: InformationCircleIcon as IconComponent, + role: "status", + ariaLive: "polite", + }, +}; + +export default function Alert({ variant, children, onDismiss, className }: AlertProps) { + const { bg, border, text, Icon, role, ariaLive } = VARIANT[variant]; + + return ( +
+ + {children} + {onDismiss && ( + + )} +
+ ); +} diff --git a/ui-react/apps/console/src/components/common/__tests__/Alert.test.tsx b/ui-react/apps/console/src/components/common/__tests__/Alert.test.tsx new file mode 100644 index 00000000000..30f3b1fe515 --- /dev/null +++ b/ui-react/apps/console/src/components/common/__tests__/Alert.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Alert, { type AlertVariant } from "@/components/common/Alert"; + +describe("Alert", () => { + describe("variant rendering", () => { + it.each<[AlertVariant, string]>([ + ["error", "bg-accent-red/8"], + ["success", "bg-accent-green/8"], + ["warning", "bg-accent-yellow/8"], + ["info", "bg-accent-blue/8"], + ])("variant=%s applies the correct background class", (variant, expected) => { + const { container } = render( + message, + ); + expect((container.firstChild as HTMLElement).className).toContain(expected); + }); + + it.each<[AlertVariant, string]>([ + ["error", "border-accent-red/20"], + ["success", "border-accent-green/20"], + ["warning", "border-accent-yellow/20"], + ["info", "border-accent-blue/20"], + ])("variant=%s applies the correct border class", (variant, expected) => { + const { container } = render( + message, + ); + expect((container.firstChild as HTMLElement).className).toContain(expected); + }); + + it.each<[AlertVariant, string]>([ + ["error", "text-accent-red"], + ["success", "text-accent-green"], + ["warning", "text-accent-yellow"], + ["info", "text-accent-blue"], + ])("variant=%s applies the correct text class", (variant, expected) => { + const { container } = render( + message, + ); + expect((container.firstChild as HTMLElement).className).toContain(expected); + }); + }); + + describe("semantic roles", () => { + it("error variant uses role=alert and aria-live=assertive", () => { + const { container } = render(Error); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute("role", "alert"); + expect(el).toHaveAttribute("aria-live", "assertive"); + }); + + it("warning variant uses role=alert and aria-live=assertive", () => { + const { container } = render(Warning); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute("role", "alert"); + expect(el).toHaveAttribute("aria-live", "assertive"); + }); + + it("success variant uses role=status and aria-live=polite", () => { + const { container } = render(Success); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute("role", "status"); + expect(el).toHaveAttribute("aria-live", "polite"); + }); + + it("info variant uses role=status and aria-live=polite", () => { + const { container } = render(Info); + const el = container.firstChild as HTMLElement; + expect(el).toHaveAttribute("role", "status"); + expect(el).toHaveAttribute("aria-live", "polite"); + }); + }); + + describe("content rendering", () => { + it("renders plain string children", () => { + render(Something went wrong); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + it("renders ReactNode children with nested elements", () => { + render( + + Error: field required + , + ); + expect(screen.getByText(/field required/)).toBeInTheDocument(); + }); + + it("is findable via its role", () => { + render(Error message); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + it("is findable via role=status for success", () => { + render(Saved successfully); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + }); + + describe("icon rendering", () => { + it("renders an icon SVG for every variant", () => { + const { container } = render(msg); + expect(container.querySelector("svg")).not.toBeNull(); + }); + + it.each(["error", "success", "warning", "info"])( + "renders exactly one icon for variant=%s", + (variant) => { + const { container } = render(msg); + expect(container.querySelectorAll("svg")).toHaveLength(1); + }, + ); + }); + + describe("dismiss behavior", () => { + it("does not render a dismiss button when onDismiss is not provided", () => { + render(Error); + expect( + screen.queryByRole("button", { name: /dismiss/i }), + ).not.toBeInTheDocument(); + }); + + it("renders a dismiss button when onDismiss is provided", () => { + render( + {}}> + Error + , + ); + expect( + screen.getByRole("button", { name: "Dismiss alert" }), + ).toBeInTheDocument(); + }); + + it("calls onDismiss when the dismiss button is clicked", async () => { + const user = userEvent.setup(); + const onDismiss = vi.fn(); + render( + + Error + , + ); + await user.click(screen.getByRole("button", { name: "Dismiss alert" })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it("dismiss button has type=button to avoid form submission", () => { + render( + {}}> + Error + , + ); + const btn = screen.getByRole("button", { name: "Dismiss alert" }); + expect(btn).toHaveAttribute("type", "button"); + }); + }); + + describe("className pass-through", () => { + it("appends extra className to the root element", () => { + const { container } = render( + + Error + , + ); + expect((container.firstChild as HTMLElement).className).toContain("mb-4"); + }); + + it("preserves built-in classes when className is provided", () => { + const { container } = render( + + Error + , + ); + const cls = (container.firstChild as HTMLElement).className; + expect(cls).toContain("rounded-md"); + expect(cls).toContain("font-mono"); + }); + + it("renders without errors when className is undefined", () => { + expect(() => + render(OK), + ).not.toThrow(); + }); + }); + + describe("base styling", () => { + it("always applies the compact inline layout classes", () => { + const { container } = render(msg); + const cls = (container.firstChild as HTMLElement).className; + expect(cls).toContain("flex items-center gap-2"); + expect(cls).toContain("px-3.5 py-2.5"); + expect(cls).toContain("rounded-md"); + expect(cls).toContain("text-xs font-mono"); + expect(cls).toContain("animate-slide-down"); + }); + }); +}); diff --git a/ui-react/apps/console/src/components/mfa/MfaDisableDialog.tsx b/ui-react/apps/console/src/components/mfa/MfaDisableDialog.tsx index 3cea5846438..57d835f2521 100644 --- a/ui-react/apps/console/src/components/mfa/MfaDisableDialog.tsx +++ b/ui-react/apps/console/src/components/mfa/MfaDisableDialog.tsx @@ -1,6 +1,7 @@ import { useState, FormEvent } from "react"; import { ExclamationTriangleIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { disableMfa } from "@/client"; +import Alert from "@/components/common/Alert"; import { useOtpInput } from "@/hooks/useOtpInput"; import { useAuthStore } from "@/stores/authStore"; import Spinner from "@/components/common/Spinner"; @@ -129,15 +130,7 @@ export default function MfaDisableDialog({
void handleSubmit(e)} className="space-y-4"> - {error && ( -
- - {error} -
- )} + {error && {error}} {mode === "totp" ? ( <> diff --git a/ui-react/apps/console/src/components/mfa/MfaEnableDrawer.tsx b/ui-react/apps/console/src/components/mfa/MfaEnableDrawer.tsx index 73696452c9c..f47f0cca2e6 100644 --- a/ui-react/apps/console/src/components/mfa/MfaEnableDrawer.tsx +++ b/ui-react/apps/console/src/components/mfa/MfaEnableDrawer.tsx @@ -2,9 +2,9 @@ import { useState, FormEvent, useEffect, useRef } from "react"; import { ShieldCheckIcon, CheckCircleIcon, - ExclamationCircleIcon, EnvelopeIcon, } from "@heroicons/react/24/outline"; +import Alert from "@/components/common/Alert"; import Drawer from "../common/Drawer"; import CheckboxField from "@/components/common/fields/CheckboxField"; import { QRCodeDisplay } from "./QRCodeDisplay"; @@ -249,15 +249,7 @@ export default function MfaEnableDrawer({ } >
- {error && ( -
- - {error} -
- )} + {error && {error}} {/* Step 1: Recovery Email */} {step === 1 && ( diff --git a/ui-react/apps/console/src/pages/ConfirmAccount.tsx b/ui-react/apps/console/src/pages/ConfirmAccount.tsx index fe304a5c829..48f261b8cc8 100644 --- a/ui-react/apps/console/src/pages/ConfirmAccount.tsx +++ b/ui-react/apps/console/src/pages/ConfirmAccount.tsx @@ -1,10 +1,7 @@ import { useEffect } from "react"; import { Link, Navigate, useSearchParams } from "react-router-dom"; -import { - EnvelopeIcon, - CheckCircleIcon, - ExclamationCircleIcon, -} from "@heroicons/react/24/outline"; +import { EnvelopeIcon } from "@heroicons/react/24/outline"; +import Alert from "@/components/common/Alert"; import { useSignUpStore } from "../stores/signUpStore"; import { useResendEmail } from "../hooks/useResendEmail"; import Spinner from "@/components/common/Spinner"; @@ -44,17 +41,15 @@ export default function ConfirmAccount() {

{resendSuccess && ( -
- + Confirmation email sent successfully. -
+ )} {resendError && ( -
- + {resendError} -
+ )}
)} diff --git a/ui-react/apps/console/src/pages/MfaLogin.tsx b/ui-react/apps/console/src/pages/MfaLogin.tsx index b45d870b3ef..bf079c49f1b 100644 --- a/ui-react/apps/console/src/pages/MfaLogin.tsx +++ b/ui-react/apps/console/src/pages/MfaLogin.tsx @@ -1,14 +1,12 @@ import { FormEvent, useEffect } from "react"; import { useNavigate, Link, useLocation } from "react-router-dom"; -import { - ExclamationCircleIcon, - ShieldCheckIcon, -} from "@heroicons/react/24/outline"; +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; import { useAuthStore } from "../stores/authStore"; import { useOtpInput } from "../hooks/useOtpInput"; import { getSafeRedirect } from "../utils/navigation"; import AuthFooterLinks from "../components/common/AuthFooterLinks"; import Spinner from "@/components/common/Spinner"; +import Alert from "@/components/common/Alert"; export default function MfaLogin() { const otp = useOtpInput(6); @@ -73,15 +71,7 @@ export default function MfaLogin() { style={{ animationDelay: "200ms" }} > void handleSubmit(e)} className="space-y-5"> - {error && ( -
- - {error} -
- )} + {error && {error}}