Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions ui-react/apps/console/src/components/common/Alert.tsx
Original file line number Diff line number Diff line change
@@ -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<AlertVariant, VariantConfig> = {
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 (
<div
role={role}
aria-live={ariaLive}
className={[
"flex items-center gap-2 border px-3.5 py-2.5 rounded-md text-xs font-mono animate-slide-down",
bg,
border,
text,
className,
]
.filter(Boolean)
.join(" ")}
>
<Icon className="w-3.5 h-3.5 shrink-0" strokeWidth={2} />
<span className="flex-1 min-w-0">{children}</span>
{onDismiss && (
<button
type="button"
aria-label="Dismiss alert"
onClick={onDismiss}
className="ml-1 hover:opacity-70 transition-opacity shrink-0 -mr-0.5"
>
<XMarkIcon className="w-3 h-3" />
</button>
)}
</div>
);
}
197 changes: 197 additions & 0 deletions ui-react/apps/console/src/components/common/__tests__/Alert.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Alert variant={variant}>message</Alert>,
);
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(
<Alert variant={variant}>message</Alert>,
);
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(
<Alert variant={variant}>message</Alert>,
);
expect((container.firstChild as HTMLElement).className).toContain(expected);
});
});

describe("semantic roles", () => {
it("error variant uses role=alert and aria-live=assertive", () => {
const { container } = render(<Alert variant="error">Error</Alert>);
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(<Alert variant="warning">Warning</Alert>);
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(<Alert variant="success">Success</Alert>);
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(<Alert variant="info">Info</Alert>);
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(<Alert variant="error">Something went wrong</Alert>);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});

it("renders ReactNode children with nested elements", () => {
render(
<Alert variant="error">
<span>Error: <strong>field required</strong></span>
</Alert>,
);
expect(screen.getByText(/field required/)).toBeInTheDocument();
});

it("is findable via its role", () => {
render(<Alert variant="error">Error message</Alert>);
expect(screen.getByRole("alert")).toBeInTheDocument();
});

it("is findable via role=status for success", () => {
render(<Alert variant="success">Saved successfully</Alert>);
expect(screen.getByRole("status")).toBeInTheDocument();
});
});

describe("icon rendering", () => {
it("renders an icon SVG for every variant", () => {
const { container } = render(<Alert variant="error">msg</Alert>);
expect(container.querySelector("svg")).not.toBeNull();
});

it.each<AlertVariant>(["error", "success", "warning", "info"])(
"renders exactly one icon for variant=%s",
(variant) => {
const { container } = render(<Alert variant={variant}>msg</Alert>);
expect(container.querySelectorAll("svg")).toHaveLength(1);
},
);
});

describe("dismiss behavior", () => {
it("does not render a dismiss button when onDismiss is not provided", () => {
render(<Alert variant="error">Error</Alert>);
expect(
screen.queryByRole("button", { name: /dismiss/i }),
).not.toBeInTheDocument();
});

it("renders a dismiss button when onDismiss is provided", () => {
render(
<Alert variant="error" onDismiss={() => {}}>
Error
</Alert>,
);
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(
<Alert variant="error" onDismiss={onDismiss}>
Error
</Alert>,
);
await user.click(screen.getByRole("button", { name: "Dismiss alert" }));
expect(onDismiss).toHaveBeenCalledOnce();
});

it("dismiss button has type=button to avoid form submission", () => {
render(
<Alert variant="error" onDismiss={() => {}}>
Error
</Alert>,
);
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(
<Alert variant="error" className="mb-4">
Error
</Alert>,
);
expect((container.firstChild as HTMLElement).className).toContain("mb-4");
});

it("preserves built-in classes when className is provided", () => {
const { container } = render(
<Alert variant="error" className="mb-4">
Error
</Alert>,
);
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(<Alert variant="success">OK</Alert>),
).not.toThrow();
});
});

describe("base styling", () => {
it("always applies the compact inline layout classes", () => {
const { container } = render(<Alert variant="info">msg</Alert>);
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");
});
});
});
11 changes: 2 additions & 9 deletions ui-react/apps/console/src/components/mfa/MfaDisableDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -129,15 +130,7 @@ export default function MfaDisableDialog({
</div>

<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
{error && (
<div className="flex items-center gap-2 bg-accent-red/8 border border-accent-red/20 text-accent-red px-3.5 py-2.5 rounded-md text-xs font-mono">
<ExclamationTriangleIcon
className="w-3.5 h-3.5 shrink-0"
strokeWidth={2}
/>
{error}
</div>
)}
{error && <Alert variant="error">{error}</Alert>}

{mode === "totp" ? (
<>
Expand Down
12 changes: 2 additions & 10 deletions ui-react/apps/console/src/components/mfa/MfaEnableDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -249,15 +249,7 @@ export default function MfaEnableDrawer({
}
>
<div className="space-y-5">
{error && (
<div className="flex items-center gap-2 bg-accent-red/8 border border-accent-red/20 text-accent-red px-3.5 py-2.5 rounded-md text-xs font-mono animate-slide-down">
<ExclamationCircleIcon
className="w-3.5 h-3.5 shrink-0"
strokeWidth={2}
/>
{error}
</div>
)}
{error && <Alert variant="error">{error}</Alert>}

{/* Step 1: Recovery Email */}
{step === 1 && (
Expand Down
17 changes: 6 additions & 11 deletions ui-react/apps/console/src/pages/ConfirmAccount.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -44,17 +41,15 @@ export default function ConfirmAccount() {
</p>

{resendSuccess && (
<div role="status" className="flex items-center gap-2 bg-accent-green/8 border border-accent-green/20 text-accent-green px-3.5 py-2.5 rounded-md text-xs font-mono animate-slide-down mb-4">
<CheckCircleIcon className="w-3.5 h-3.5 shrink-0" strokeWidth={2} />
<Alert variant="success" className="mb-4">
Confirmation email sent successfully.
</div>
</Alert>
)}

{resendError && (
<div role="alert" className="flex items-center gap-2 bg-accent-red/8 border border-accent-red/20 text-accent-red px-3.5 py-2.5 rounded-md text-xs font-mono animate-slide-down mb-4">
<ExclamationCircleIcon className="w-3.5 h-3.5 shrink-0" strokeWidth={2} />
<Alert variant="error" className="mb-4">
{resendError}
</div>
</Alert>
)}

<button
Expand Down
Loading
Loading