feat(#415): theme fix, AuthDivider, SessionExpiryWarning components
- AUTH-014: Fix theme storage key (jarvis-theme -> mosaic-theme) - AUTH-016: Create AuthDivider component with customizable text - AUTH-019: Create SessionExpiryWarning floating banner (PDA-friendly, blue) - Fix lint errors in LoginForm, OAuthButton from parallel agents - Sync pnpm-lock.yaml for recharts dependency Refs #415 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
apps/web/src/components/auth/AuthDivider.test.tsx
Normal file
27
apps/web/src/components/auth/AuthDivider.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AuthDivider } from "./AuthDivider";
|
||||
|
||||
describe("AuthDivider", (): void => {
|
||||
it("should render with default text", (): void => {
|
||||
render(<AuthDivider />);
|
||||
expect(screen.getByText("or continue with email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with custom text", (): void => {
|
||||
render(<AuthDivider text="or sign up" />);
|
||||
expect(screen.getByText("or sign up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a horizontal divider line", (): void => {
|
||||
const { container } = render(<AuthDivider />);
|
||||
const line = container.querySelector("span.border-t");
|
||||
expect(line).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply uppercase styling to text", (): void => {
|
||||
const { container } = render(<AuthDivider />);
|
||||
const textWrapper = container.querySelector(".uppercase");
|
||||
expect(textWrapper).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
18
apps/web/src/components/auth/AuthDivider.tsx
Normal file
18
apps/web/src/components/auth/AuthDivider.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
interface AuthDividerProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function AuthDivider({
|
||||
text = "or continue with email",
|
||||
}: AuthDividerProps): React.ReactElement {
|
||||
return (
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-slate-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-slate-500">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
apps/web/src/components/auth/AuthErrorBanner.test.tsx
Normal file
79
apps/web/src/components/auth/AuthErrorBanner.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AuthErrorBanner } from "./AuthErrorBanner";
|
||||
|
||||
describe("AuthErrorBanner", (): void => {
|
||||
it("should render the message text", (): void => {
|
||||
render(<AuthErrorBanner message="Authentication paused. Please try again when ready." />);
|
||||
expect(
|
||||
screen.getByText("Authentication paused. Please try again when ready.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have role alert and aria-live polite for accessibility", (): void => {
|
||||
render(<AuthErrorBanner message="Unable to connect." />);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(alert).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("should render the info icon, not a warning icon", (): void => {
|
||||
const { container } = render(<AuthErrorBanner message="Test message" />);
|
||||
// Info icon from lucide-react renders as an SVG
|
||||
const svgs = container.querySelectorAll("svg");
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(1);
|
||||
// The container should use blue styling, not red/yellow
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.className).toContain("bg-blue-50");
|
||||
expect(alert.className).toContain("text-blue-700");
|
||||
expect(alert.className).not.toContain("red");
|
||||
expect(alert.className).not.toContain("yellow");
|
||||
});
|
||||
|
||||
it("should render dismiss button when onDismiss is provided", (): void => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<AuthErrorBanner message="Test message" onDismiss={onDismiss} />);
|
||||
const dismissButton = screen.getByLabelText("Dismiss");
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render dismiss button when onDismiss is not provided", (): void => {
|
||||
render(<AuthErrorBanner message="Test message" />);
|
||||
expect(screen.queryByLabelText("Dismiss")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onDismiss when dismiss button is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
render(<AuthErrorBanner message="Test message" onDismiss={onDismiss} />);
|
||||
|
||||
const dismissButton = screen.getByLabelText("Dismiss");
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should use blue info styling, not red or alarming colors", (): void => {
|
||||
render(<AuthErrorBanner message="Test" />);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.className).toContain("bg-blue-50");
|
||||
expect(alert.className).toContain("border-blue-200");
|
||||
expect(alert.className).toContain("text-blue-700");
|
||||
});
|
||||
|
||||
it("should render all PDA-friendly error messages", (): void => {
|
||||
const messages = [
|
||||
"Authentication paused. Please try again when ready.",
|
||||
"The email and password combination wasn't recognized.",
|
||||
"Unable to connect. Check your network and try again.",
|
||||
"The service is taking a break. Please try again in a moment.",
|
||||
];
|
||||
|
||||
for (const message of messages) {
|
||||
const { unmount } = render(<AuthErrorBanner message={message} />);
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
32
apps/web/src/components/auth/AuthErrorBanner.tsx
Normal file
32
apps/web/src/components/auth/AuthErrorBanner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
|
||||
export interface AuthErrorBannerProps {
|
||||
message: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-blue-50 border border-blue-200 text-blue-700 rounded-lg p-4 flex items-start gap-3"
|
||||
>
|
||||
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<span className="flex-1 text-sm">{message}</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="flex-shrink-0 text-blue-500 hover:text-blue-700 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/components/auth/LoginForm.test.tsx
Normal file
168
apps/web/src/components/auth/LoginForm.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LoginForm } from "./LoginForm";
|
||||
|
||||
describe("LoginForm", (): void => {
|
||||
const mockOnSubmit = vi.fn();
|
||||
|
||||
beforeEach((): void => {
|
||||
mockOnSubmit.mockClear();
|
||||
});
|
||||
|
||||
it("should render email and password fields with labels", (): void => {
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Password")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a Continue submit button", (): void => {
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
expect(screen.getByRole("button", { name: "Continue" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should auto-focus the email input on mount", (): void => {
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
expect(document.activeElement).toBe(emailInput);
|
||||
});
|
||||
|
||||
it("should validate email format on submit", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const passwordInput = screen.getByLabelText("Password");
|
||||
const submitButton = screen.getByRole("button", { name: "Continue" });
|
||||
|
||||
await user.type(emailInput, "invalid-email");
|
||||
await user.type(passwordInput, "password123");
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(screen.getByText("Please enter a valid email address.")).toBeInTheDocument();
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should validate non-empty password on submit", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const submitButton = screen.getByRole("button", { name: "Continue" });
|
||||
|
||||
await user.type(emailInput, "user@example.com");
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(screen.getByText("Password is recommended.")).toBeInTheDocument();
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onSubmit with email and password when valid", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const passwordInput = screen.getByLabelText("Password");
|
||||
const submitButton = screen.getByRole("button", { name: "Continue" });
|
||||
|
||||
await user.type(emailInput, "user@example.com");
|
||||
await user.type(passwordInput, "password123");
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith("user@example.com", "password123");
|
||||
});
|
||||
|
||||
it("should show loading state with spinner and Signing in text", (): void => {
|
||||
render(<LoginForm onSubmit={mockOnSubmit} isLoading={true} />);
|
||||
expect(screen.getByText("Signing in...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Continue")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable inputs when loading", (): void => {
|
||||
render(<LoginForm onSubmit={mockOnSubmit} isLoading={true} />);
|
||||
expect(screen.getByLabelText("Email")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Password")).toBeDisabled();
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should display error message when error prop is provided", (): void => {
|
||||
render(
|
||||
<LoginForm
|
||||
onSubmit={mockOnSubmit}
|
||||
error="The email and password combination wasn't recognized."
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("The email and password combination wasn't recognized.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should dismiss error when dismiss button is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LoginForm
|
||||
onSubmit={mockOnSubmit}
|
||||
error="Authentication paused. Please try again when ready."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Authentication paused. Please try again when ready.")
|
||||
).toBeInTheDocument();
|
||||
|
||||
const dismissButton = screen.getByLabelText("Dismiss");
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(
|
||||
screen.queryByText("Authentication paused. Please try again when ready.")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have htmlFor on email label pointing to email input", (): void => {
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
const emailLabel = screen.getByText("Email");
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
expect(emailLabel).toHaveAttribute("for", emailInput.id);
|
||||
});
|
||||
|
||||
it("should have htmlFor on password label pointing to password input", (): void => {
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
const passwordLabel = screen.getByText("Password");
|
||||
const passwordInput = screen.getByLabelText("Password");
|
||||
expect(passwordLabel).toHaveAttribute("for", passwordInput.id);
|
||||
});
|
||||
|
||||
it("should clear email validation error when user types a valid email", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const submitButton = screen.getByRole("button", { name: "Continue" });
|
||||
|
||||
// Trigger validation error
|
||||
await user.type(emailInput, "invalid");
|
||||
await user.click(submitButton);
|
||||
expect(screen.getByText("Please enter a valid email address.")).toBeInTheDocument();
|
||||
|
||||
// Fix the email
|
||||
await user.clear(emailInput);
|
||||
await user.type(emailInput, "user@example.com");
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.queryByText("Please enter a valid email address.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should set aria-invalid on email input when validation fails", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
|
||||
const emailInput = screen.getByLabelText("Email");
|
||||
const submitButton = screen.getByRole("button", { name: "Continue" });
|
||||
|
||||
await user.type(emailInput, "invalid");
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(emailInput).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
});
|
||||
172
apps/web/src/components/auth/LoginForm.tsx
Normal file
172
apps/web/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { AuthErrorBanner } from "./AuthErrorBanner";
|
||||
|
||||
export interface LoginFormProps {
|
||||
onSubmit: (email: string, password: string) => void | Promise<void>;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
}: LoginFormProps): ReactElement {
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [dismissedError, setDismissedError] = useState(false);
|
||||
|
||||
useEffect((): void => {
|
||||
emailRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Reset dismissed state when a new error comes in
|
||||
useEffect((): void => {
|
||||
if (error) {
|
||||
setDismissedError(false);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const validateEmail = useCallback((value: string): boolean => {
|
||||
if (!value.includes("@")) {
|
||||
setEmailError("Please enter a valid email address.");
|
||||
return false;
|
||||
}
|
||||
setEmailError(null);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const validatePassword = useCallback((value: string): boolean => {
|
||||
if (value.length === 0) {
|
||||
setPasswordError("Password is recommended.");
|
||||
return false;
|
||||
}
|
||||
setPasswordError(null);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
|
||||
const isEmailValid = validateEmail(email);
|
||||
const isPasswordValid = validatePassword(password);
|
||||
|
||||
if (!isEmailValid || !isPasswordValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onSubmit(email, password);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
{error && !dismissedError && (
|
||||
<AuthErrorBanner
|
||||
message={error}
|
||||
onDismiss={(): void => {
|
||||
setDismissedError(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="login-email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
ref={emailRef}
|
||||
id="login-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e): void => {
|
||||
setEmail(e.target.value);
|
||||
if (emailError) {
|
||||
validateEmail(e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
className={[
|
||||
"w-full px-3 py-2 border rounded-md",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
|
||||
emailError ? "border-blue-400" : "border-gray-300",
|
||||
isLoading ? "opacity-50" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-invalid={emailError ? "true" : "false"}
|
||||
aria-describedby={emailError ? "login-email-error" : undefined}
|
||||
/>
|
||||
{emailError && (
|
||||
<p id="login-email-error" className="mt-1 text-sm text-blue-600" role="alert">
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="login-password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e): void => {
|
||||
setPassword(e.target.value);
|
||||
if (passwordError) {
|
||||
validatePassword(e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
className={[
|
||||
"w-full px-3 py-2 border rounded-md",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
|
||||
passwordError ? "border-blue-400" : "border-gray-300",
|
||||
isLoading ? "opacity-50" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-invalid={passwordError ? "true" : "false"}
|
||||
aria-describedby={passwordError ? "login-password-error" : undefined}
|
||||
/>
|
||||
{passwordError && (
|
||||
<p id="login-password-error" className="mt-1 text-sm text-blue-600" role="alert">
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={[
|
||||
"w-full inline-flex items-center justify-center gap-2",
|
||||
"rounded-md px-4 py-2 text-base font-medium",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
isLoading ? "opacity-50 pointer-events-none" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
<span>Signing in...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Continue</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/components/auth/OAuthButton.test.tsx
Normal file
89
apps/web/src/components/auth/OAuthButton.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { OAuthButton } from "./OAuthButton";
|
||||
|
||||
describe("OAuthButton", (): void => {
|
||||
const defaultProps = {
|
||||
providerName: "Authentik",
|
||||
providerId: "authentik",
|
||||
onClick: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render with provider name", (): void => {
|
||||
render(<OAuthButton {...defaultProps} />);
|
||||
expect(screen.getByText("Continue with Authentik")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have full width styling", (): void => {
|
||||
render(<OAuthButton {...defaultProps} />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("w-full");
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(<OAuthButton {...defaultProps} onClick={onClick} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show loading state with spinner and Connecting text", (): void => {
|
||||
render(<OAuthButton {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByText("Connecting...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Continue with Authentik")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be disabled when isLoading is true", (): void => {
|
||||
render(<OAuthButton {...defaultProps} isLoading={true} />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should be disabled when disabled prop is true", (): void => {
|
||||
render(<OAuthButton {...defaultProps} disabled={true} />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should have reduced opacity when disabled", (): void => {
|
||||
render(<OAuthButton {...defaultProps} disabled={true} />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("opacity-50");
|
||||
expect(button.className).toContain("pointer-events-none");
|
||||
});
|
||||
|
||||
it("should have aria-label with provider name", (): void => {
|
||||
render(<OAuthButton {...defaultProps} />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toHaveAttribute("aria-label", "Continue with Authentik");
|
||||
});
|
||||
|
||||
it("should have aria-label Connecting when loading", (): void => {
|
||||
render(<OAuthButton {...defaultProps} isLoading={true} />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toHaveAttribute("aria-label", "Connecting");
|
||||
});
|
||||
|
||||
it("should render a spinner SVG when loading", (): void => {
|
||||
const { container } = render(<OAuthButton {...defaultProps} isLoading={true} />);
|
||||
const spinner = container.querySelector("svg");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(spinner?.getAttribute("class")).toContain("animate-spin");
|
||||
});
|
||||
|
||||
it("should not call onClick when disabled", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(<OAuthButton {...defaultProps} onClick={onClick} disabled={true} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
await user.click(button);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
49
apps/web/src/components/auth/OAuthButton.tsx
Normal file
49
apps/web/src/components/auth/OAuthButton.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export interface OAuthButtonProps {
|
||||
providerName: string;
|
||||
providerId: string;
|
||||
onClick: () => void;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function OAuthButton({
|
||||
providerName,
|
||||
onClick,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}: OAuthButtonProps): ReactElement {
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`}
|
||||
className={[
|
||||
"w-full inline-flex items-center justify-center gap-2",
|
||||
"rounded-md px-4 py-2 text-base font-medium",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
isDisabled ? "opacity-50 pointer-events-none" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
<span>Connecting...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Continue with {providerName}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
79
apps/web/src/components/auth/SessionExpiryWarning.test.tsx
Normal file
79
apps/web/src/components/auth/SessionExpiryWarning.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SessionExpiryWarning } from "./SessionExpiryWarning";
|
||||
|
||||
describe("SessionExpiryWarning", (): void => {
|
||||
it("should render with minutes remaining", (): void => {
|
||||
render(<SessionExpiryWarning minutesRemaining={5} />);
|
||||
expect(screen.getByText(/your session will end in 5 minutes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use singular 'minute' when 1 minute remaining", (): void => {
|
||||
render(<SessionExpiryWarning minutesRemaining={1} />);
|
||||
expect(screen.getByText(/your session will end in 1 minute\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show extend button when onExtend is provided", (): void => {
|
||||
const onExtend = vi.fn();
|
||||
render(<SessionExpiryWarning minutesRemaining={3} onExtend={onExtend} />);
|
||||
expect(screen.getByText("Extend Session")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show extend button when onExtend is not provided", (): void => {
|
||||
render(<SessionExpiryWarning minutesRemaining={3} />);
|
||||
expect(screen.queryByText("Extend Session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onExtend when extend button is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onExtend = vi.fn();
|
||||
render(<SessionExpiryWarning minutesRemaining={3} onExtend={onExtend} />);
|
||||
|
||||
await user.click(screen.getByText("Extend Session"));
|
||||
expect(onExtend).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show dismiss button when onDismiss is provided", (): void => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<SessionExpiryWarning minutesRemaining={3} onDismiss={onDismiss} />);
|
||||
expect(screen.getByLabelText("Dismiss")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show dismiss button when onDismiss is not provided", (): void => {
|
||||
render(<SessionExpiryWarning minutesRemaining={3} />);
|
||||
expect(screen.queryByLabelText("Dismiss")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onDismiss when dismiss button is clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
render(<SessionExpiryWarning minutesRemaining={3} onDismiss={onDismiss} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Dismiss"));
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should have role='status' for accessibility", (): void => {
|
||||
render(<SessionExpiryWarning minutesRemaining={5} />);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have aria-live='polite' for screen readers", (): void => {
|
||||
render(<SessionExpiryWarning minutesRemaining={5} />);
|
||||
const statusElement = screen.getByRole("status");
|
||||
expect(statusElement).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("should use blue theme (not red) for PDA-friendly design", (): void => {
|
||||
render(<SessionExpiryWarning minutesRemaining={5} />);
|
||||
const statusElement = screen.getByRole("status");
|
||||
expect(statusElement.className).toContain("bg-blue-50");
|
||||
expect(statusElement.className).toContain("border-blue-200");
|
||||
});
|
||||
|
||||
it("should include saving work reminder in message", (): void => {
|
||||
render(<SessionExpiryWarning minutesRemaining={5} />);
|
||||
expect(screen.getByText(/consider saving your work/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
50
apps/web/src/components/auth/SessionExpiryWarning.tsx
Normal file
50
apps/web/src/components/auth/SessionExpiryWarning.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Info, X } from "lucide-react";
|
||||
|
||||
interface SessionExpiryWarningProps {
|
||||
minutesRemaining: number;
|
||||
onExtend?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function SessionExpiryWarning({
|
||||
minutesRemaining,
|
||||
onExtend,
|
||||
onDismiss,
|
||||
}: SessionExpiryWarningProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-4 right-4 z-50 flex max-w-sm items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 p-4 shadow-lg"
|
||||
>
|
||||
<Info className="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-500" aria-hidden="true" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-blue-700">
|
||||
Your session will end in {minutesRemaining}{" "}
|
||||
{minutesRemaining === 1 ? "minute" : "minutes"}. Consider saving your work.
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{onExtend ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExtend}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
>
|
||||
Extend Session
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{onDismiss ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
className="flex-shrink-0 text-blue-400 hover:text-blue-600 focus:outline-none"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/web/src/providers/ThemeProvider.test.tsx
Normal file
120
apps/web/src/providers/ThemeProvider.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { ThemeProvider, useTheme } from "./ThemeProvider";
|
||||
|
||||
function ThemeConsumer(): React.JSX.Element {
|
||||
const { theme, resolvedTheme, setTheme, toggleTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="resolved">{resolvedTheme}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme("light");
|
||||
}}
|
||||
>
|
||||
Set Light
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme("dark");
|
||||
}}
|
||||
>
|
||||
Set Dark
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleTheme();
|
||||
}}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ThemeProvider", (): void => {
|
||||
let mockMatchMedia: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach((): void => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
|
||||
mockMatchMedia = vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: mockMatchMedia,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should use 'mosaic-theme' as storage key", (): void => {
|
||||
localStorage.setItem("mosaic-theme", "light");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("light");
|
||||
});
|
||||
|
||||
it("should NOT read from old 'jarvis-theme' storage key", (): void => {
|
||||
localStorage.setItem("jarvis-theme", "light");
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Should default to system, not read from jarvis-theme
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("system");
|
||||
});
|
||||
|
||||
it("should store theme under 'mosaic-theme' key", (): void => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Set Light").click();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("mosaic-theme")).toBe("light");
|
||||
expect(localStorage.getItem("jarvis-theme")).toBeNull();
|
||||
});
|
||||
|
||||
it("should render children", (): void => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<div data-testid="child">Hello</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should throw when useTheme is used outside provider", (): void => {
|
||||
// Suppress console.error for expected error
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
render(<ThemeConsumer />);
|
||||
}).toThrow("useTheme must be used within a ThemeProvider");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ interface ThemeContextValue {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "jarvis-theme";
|
||||
const STORAGE_KEY = "mosaic-theme";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -7408,7 +7408,7 @@ snapshots:
|
||||
chalk: 5.6.2
|
||||
commander: 12.1.0
|
||||
dotenv: 17.2.4
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
open: 10.2.0
|
||||
pg: 8.17.2
|
||||
prettier: 3.8.1
|
||||
@@ -10410,7 +10410,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
better-sqlite3: 12.6.2
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
@@ -10435,7 +10435,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||
better-sqlite3: 12.6.2
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
@@ -11229,6 +11229,17 @@ snapshots:
|
||||
|
||||
dotenv@17.2.4: {}
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
'@types/pg': 8.16.0
|
||||
better-sqlite3: 12.6.2
|
||||
kysely: 0.28.10
|
||||
pg: 8.17.2
|
||||
postgres: 3.4.8
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -11239,6 +11250,7 @@ snapshots:
|
||||
pg: 8.17.2
|
||||
postgres: 3.4.8
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user