fix(#411): auth & frontend remediation — all 6 phases complete #418
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 ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
const STORAGE_KEY = "jarvis-theme";
|
const STORAGE_KEY = "mosaic-theme";
|
||||||
|
|
||||||
function getSystemTheme(): "light" | "dark" {
|
function getSystemTheme(): "light" | "dark" {
|
||||||
if (typeof window === "undefined") return "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
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.4
|
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
|
open: 10.2.0
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
@@ -10410,7 +10410,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.6.2
|
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)
|
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
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -10435,7 +10435,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
'@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
|
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)
|
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
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -11229,6 +11229,17 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.4: {}
|
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)):
|
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:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -11239,6 +11250,7 @@ snapshots:
|
|||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
postgres: 3.4.8
|
postgres: 3.4.8
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
|
optional: true
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user