fix(web): restore login page design and add runtime config injection #435

Merged
jason.woltje merged 1 commits from fix/login-page-design into main 2026-02-21 23:16:02 +00:00
12 changed files with 416 additions and 150 deletions

View File

@@ -127,8 +127,8 @@ describe("LoginPage", (): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
}); });
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack"); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Command Center");
expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument(); expect(screen.getByText(/Sign in to your orchestration platform/i)).toBeInTheDocument();
}); });
it("has proper layout styling", async (): Promise<void> => { it("has proper layout styling", async (): Promise<void> => {
@@ -186,7 +186,7 @@ describe("LoginPage", (): void => {
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
}); });
expect(screen.getByText(/or continue with email/i)).toBeInTheDocument(); expect(screen.getByText(/or continue with/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
}); });
@@ -200,7 +200,11 @@ describe("LoginPage", (): void => {
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
}); });
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument(); // The divider element should not appear (no credentials provider)
const dividerTexts = screen.queryAllByText(/or continue with/i);
// OAuthButton text contains "Continue with" so filter for the divider specifically
const dividerOnly = dividerTexts.filter((el) => el.textContent === "or continue with");
expect(dividerOnly).toHaveLength(0);
}); });
it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => { it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => {
@@ -215,7 +219,6 @@ describe("LoginPage", (): void => {
// Should NOT silently fall back to email form // Should NOT silently fall back to email form
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument();
// Should show the error banner with helpful message // Should show the error banner with helpful message
expect( expect(
@@ -453,7 +456,7 @@ describe("LoginPage", (): void => {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
describe("responsive layout", (): void => { describe("responsive layout", (): void => {
it("applies mobile-first padding to main element", async (): Promise<void> => { it("applies AuthShell layout classes to main element", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG); mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />); const { container } = render(<LoginPage />);
@@ -463,8 +466,7 @@ describe("LoginPage", (): void => {
}); });
const main = container.querySelector("main"); const main = container.querySelector("main");
expect(main).toHaveClass("min-h-screen", "items-center", "justify-center");
expect(main).toHaveClass("p-4", "sm:p-8");
}); });
it("applies responsive text size to heading", async (): Promise<void> => { it("applies responsive text size to heading", async (): Promise<void> => {
@@ -477,10 +479,10 @@ describe("LoginPage", (): void => {
}); });
const heading = screen.getByRole("heading", { level: 1 }); const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveClass("text-2xl", "sm:text-4xl"); expect(heading).toHaveClass("text-xl", "sm:text-2xl");
}); });
it("applies responsive padding to card container", async (): Promise<void> => { it("AuthCard applies card styling with padding", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG); mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />); const { container } = render(<LoginPage />);
@@ -489,12 +491,12 @@ describe("LoginPage", (): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
}); });
const card = container.querySelector(".bg-white"); // AuthCard uses rounded-2xl and p-6 sm:p-10
const card = container.querySelector(".rounded-2xl");
expect(card).toHaveClass("p-4", "sm:p-8"); expect(card).toHaveClass("p-6", "sm:p-10");
}); });
it("card container has full width with max-width constraint", async (): Promise<void> => { it("AuthShell constrains card width", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG); mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />); const { container } = render(<LoginPage />);
@@ -503,9 +505,9 @@ describe("LoginPage", (): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
}); });
const wrapper = container.querySelector(".max-w-md"); // AuthShell wraps children in max-w-[27rem]
const wrapper = container.querySelector(".max-w-\\[27rem\\]");
expect(wrapper).toHaveClass("w-full", "max-w-md"); expect(wrapper).toHaveClass("w-full");
}); });
}); });

View File

@@ -5,6 +5,7 @@ import type { ReactElement } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared"; import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
import { AuthShell, AuthCard, AuthBrand, AuthStatusPill } from "@mosaic/ui";
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config"; import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
import { signIn } from "@/lib/auth-client"; import { signIn } from "@/lib/auth-client";
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry"; import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
@@ -19,23 +20,21 @@ export default function LoginPage(): ReactElement {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50"> <AuthShell>
<div className="w-full max-w-md space-y-8"> <AuthCard>
<div className="text-center"> <div className="flex flex-col items-center gap-6">
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1> <AuthBrand />
</div>
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
<div <div
className="flex items-center justify-center py-8" className="flex items-center justify-center py-8"
role="status" role="status"
aria-label="Loading authentication options" aria-label="Loading authentication options"
> >
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" /> <Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
<span className="sr-only">Loading authentication options</span> <span className="sr-only">Loading authentication options</span>
</div> </div>
</div> </div>
</div> </AuthCard>
</main> </AuthShell>
} }
> >
<LoginPageContent /> <LoginPageContent />
@@ -185,47 +184,51 @@ function LoginPageContent(): ReactElement {
if (IS_MOCK_AUTH_MODE) { if (IS_MOCK_AUTH_MODE) {
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50"> <AuthShell>
<div className="w-full max-w-md space-y-8"> <AuthCard>
<div className="text-center"> <div className="flex flex-col items-center gap-6">
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1> <AuthBrand />
<p className="text-base sm:text-lg text-gray-600"> <div className="text-center">
Local mock auth mode is active. Real sign-in is bypassed for frontend development. <h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
</p> <p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
</div> Local mock auth mode is active
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md space-y-4"> </p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
Mock auth mode is local-only and blocked outside development.
</div> </div>
</div>
<div className="mt-6 space-y-4">
<AuthStatusPill label="Mock mode" tone="warning" className="w-full justify-center" />
{error && <AuthErrorBanner message={error} />} {error && <AuthErrorBanner message={error} />}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
void handleMockLogin(); void handleMockLogin();
}} }}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors" className="w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
data-testid="mock-auth-login" data-testid="mock-auth-login"
> >
Continue with Mock Session Continue with Mock Session
</button> </button>
</div> </div>
</div> </AuthCard>
</main> </AuthShell>
); );
} }
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50"> <AuthShell>
<div className="w-full max-w-md space-y-8"> <AuthCard>
<div className="text-center"> <div className="flex flex-col items-center gap-6">
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1> <AuthBrand />
<p className="text-base sm:text-lg text-gray-600"> <div className="text-center">
Your personal assistant platform. Organize tasks, events, and projects with a <h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
PDA-friendly approach. <p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
</p> Sign in to your orchestration platform
</p>
</div>
</div> </div>
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md"> <div className="mt-6">
{loadingConfig ? ( {loadingConfig ? (
<div <div
className="flex items-center justify-center py-8" className="flex items-center justify-center py-8"
@@ -233,7 +236,7 @@ function LoginPageContent(): ReactElement {
role="status" role="status"
aria-label="Loading authentication options" aria-label="Loading authentication options"
> >
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" /> <Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
<span className="sr-only">Loading authentication options</span> <span className="sr-only">Loading authentication options</span>
</div> </div>
) : config === null ? ( ) : config === null ? (
@@ -243,47 +246,35 @@ function LoginPageContent(): ReactElement {
<button <button
type="button" type="button"
onClick={handleRetry} onClick={handleRetry}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" className="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
> >
Try again Try again
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<> <div className="space-y-0">
{urlError && ( {urlError && (
<AuthErrorBanner <div className="mb-4">
message={urlError} <AuthErrorBanner
onDismiss={(): void => { message={urlError}
setUrlError(null); onDismiss={(): void => {
}} setUrlError(null);
/> }}
/>
</div>
)} )}
{error && !hasCredentials && ( {error && !hasCredentials && (
<AuthErrorBanner <div className="mb-4">
message={error} <AuthErrorBanner
onDismiss={(): void => { message={error}
setError(null); onDismiss={(): void => {
}} setError(null);
/>
)}
{hasOAuth &&
oauthProviders.map((provider) => (
<OAuthButton
key={provider.id}
providerName={provider.name}
providerId={provider.id}
onClick={(): void => {
handleOAuthLogin(provider.id);
}} }}
isLoading={oauthLoading === provider.id}
disabled={oauthLoading !== null && oauthLoading !== provider.id}
/> />
))} </div>
)}
{hasOAuth && hasCredentials && <AuthDivider />}
{hasCredentials && ( {hasCredentials && (
<LoginForm <LoginForm
@@ -292,10 +283,33 @@ function LoginPageContent(): ReactElement {
error={error} error={error}
/> />
)} )}
</>
{hasOAuth && hasCredentials && <AuthDivider />}
{hasOAuth && (
<div className="space-y-2">
{oauthProviders.map((provider) => (
<OAuthButton
key={provider.id}
providerName={provider.name}
providerId={provider.id}
onClick={(): void => {
handleOAuthLogin(provider.id);
}}
isLoading={oauthLoading === provider.id}
disabled={oauthLoading !== null && oauthLoading !== provider.id}
/>
))}
</div>
)}
</div>
)} )}
</div> </div>
</div>
</main> <div className="mt-6 flex justify-center">
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
</div>
</AuthCard>
</AuthShell>
); );
} }

View File

@@ -10,9 +10,32 @@ export const metadata: Metadata = {
description: "Mosaic Stack Web Application", description: "Mosaic Stack Web Application",
}; };
/**
* Runtime env vars injected as a synchronous script so client-side modules
* can read them before React hydration. This allows Docker env vars to
* override the build-time baked NEXT_PUBLIC_* values.
*/
function runtimeEnvScript(): string {
const env: Record<string, string> = {};
for (const key of [
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_ORCHESTRATOR_URL",
"NEXT_PUBLIC_AUTH_MODE",
]) {
const value = process.env[key];
if (value) {
env[key] = value;
}
}
return `window.__MOSAIC_ENV__=${JSON.stringify(env)};`;
}
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element { export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
return ( return (
<html lang="en"> <html lang="en">
<head>
<script dangerouslySetInnerHTML={{ __html: runtimeEnvScript() }} />
</head>
<body> <body>
<ThemeProvider> <ThemeProvider>
<ErrorBoundary> <ErrorBoundary>

View File

@@ -5,7 +5,7 @@ import { AuthDivider } from "./AuthDivider";
describe("AuthDivider", (): void => { describe("AuthDivider", (): void => {
it("should render with default text", (): void => { it("should render with default text", (): void => {
render(<AuthDivider />); render(<AuthDivider />);
expect(screen.getByText("or continue with email")).toBeInTheDocument(); expect(screen.getByText("or continue with")).toBeInTheDocument();
}); });
it("should render with custom text", (): void => { it("should render with custom text", (): void => {
@@ -13,10 +13,10 @@ describe("AuthDivider", (): void => {
expect(screen.getByText("or sign up")).toBeInTheDocument(); expect(screen.getByText("or sign up")).toBeInTheDocument();
}); });
it("should render a horizontal divider line", (): void => { it("should render horizontal divider lines", (): void => {
const { container } = render(<AuthDivider />); const { container } = render(<AuthDivider />);
const line = container.querySelector("span.border-t"); const lines = container.querySelectorAll("[aria-hidden='true'].h-px");
expect(line).toBeInTheDocument(); expect(lines.length).toBe(2);
}); });
it("should apply uppercase styling to text", (): void => { it("should apply uppercase styling to text", (): void => {

View File

@@ -1,18 +1,2 @@
interface AuthDividerProps { export { AuthDivider } from "@mosaic/ui";
text?: string; export type { AuthDividerProps } from "@mosaic/ui";
}
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>
);
}

View File

@@ -18,17 +18,10 @@ describe("AuthErrorBanner", (): void => {
expect(alert).toHaveAttribute("aria-live", "polite"); expect(alert).toHaveAttribute("aria-live", "polite");
}); });
it("should render the info icon, not a warning icon", (): void => { it("should render an icon", (): void => {
const { container } = render(<AuthErrorBanner message="Test message" />); const { container } = render(<AuthErrorBanner message="Test message" />);
// Info icon from lucide-react renders as an SVG
const svgs = container.querySelectorAll("svg"); const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBeGreaterThanOrEqual(1); 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 => { it("should render dismiss button when onDismiss is provided", (): void => {
@@ -54,14 +47,6 @@ describe("AuthErrorBanner", (): void => {
expect(onDismiss).toHaveBeenCalledTimes(1); 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 => { it("should render all PDA-friendly error messages", (): void => {
const messages = [ const messages = [
"Authentication paused. Please try again when ready.", "Authentication paused. Please try again when ready.",

View File

@@ -13,7 +13,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
<div <div
role="alert" role="alert"
aria-live="polite" aria-live="polite"
className="bg-blue-50 border border-blue-200 text-blue-700 rounded-lg p-4 flex items-start gap-3" className="flex items-start gap-3 rounded-lg border border-[#f06a6f]/55 bg-[#fff1f2] p-4 text-[#9f1239] dark:border-[#e5484d]/55 dark:bg-[#3a111b]/70 dark:text-[#fecdd3]"
> >
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" /> <Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="flex-1 text-sm">{message}</span> <span className="flex-1 text-sm">{message}</span>
@@ -21,7 +21,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
<button <button
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
className="flex-shrink-0 text-blue-500 hover:text-blue-700 transition-colors" className="flex-shrink-0 text-[#be123c] transition-colors hover:text-[#881337] dark:text-[#fda4af] dark:hover:text-[#ffe4e6]"
aria-label="Dismiss" aria-label="Dismiss"
> >
<X className="h-4 w-4" aria-hidden="true" /> <X className="h-4 w-4" aria-hidden="true" />

View File

@@ -9,12 +9,14 @@ export interface LoginFormProps {
onSubmit: (email: string, password: string) => void | Promise<void>; onSubmit: (email: string, password: string) => void | Promise<void>;
isLoading?: boolean; isLoading?: boolean;
error?: string | null; error?: string | null;
disabled?: boolean;
} }
export function LoginForm({ export function LoginForm({
onSubmit, onSubmit,
isLoading = false, isLoading = false,
error = null, error = null,
disabled = false,
}: LoginFormProps): ReactElement { }: LoginFormProps): ReactElement {
const emailRef = useRef<HTMLInputElement>(null); const emailRef = useRef<HTMLInputElement>(null);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -77,7 +79,10 @@ export function LoginForm({
)} )}
<div> <div>
<label htmlFor="login-email" className="block text-sm font-medium text-gray-700 mb-1"> <label
htmlFor="login-email"
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
>
Email Email
</label> </label>
<input <input
@@ -91,13 +96,17 @@ export function LoginForm({
validateEmail(e.target.value); validateEmail(e.target.value);
} }
}} }}
disabled={isLoading} disabled={isLoading || disabled}
autoComplete="email" autoComplete="email"
className={[ className={[
"w-full px-3 py-2 border rounded-md", "w-full rounded-lg border px-3.5 py-2.5 text-sm",
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors", "bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
emailError ? "border-blue-400" : "border-gray-300", "transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
isLoading ? "opacity-50" : "", "dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
emailError
? "border-[#f06a6f] focus:border-[#e5484d]"
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
isLoading || disabled ? "opacity-50" : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
@@ -105,14 +114,21 @@ export function LoginForm({
aria-describedby={emailError ? "login-email-error" : undefined} aria-describedby={emailError ? "login-email-error" : undefined}
/> />
{emailError && ( {emailError && (
<p id="login-email-error" className="mt-1 text-sm text-blue-600" role="alert"> <p
id="login-email-error"
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
role="alert"
>
{emailError} {emailError}
</p> </p>
)} )}
</div> </div>
<div> <div>
<label htmlFor="login-password" className="block text-sm font-medium text-gray-700 mb-1"> <label
htmlFor="login-password"
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
>
Password Password
</label> </label>
<input <input
@@ -125,13 +141,17 @@ export function LoginForm({
validatePassword(e.target.value); validatePassword(e.target.value);
} }
}} }}
disabled={isLoading} disabled={isLoading || disabled}
autoComplete="current-password" autoComplete="current-password"
className={[ className={[
"w-full px-3 py-2 border rounded-md", "w-full rounded-lg border px-3.5 py-2.5 text-sm",
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors", "bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
passwordError ? "border-blue-400" : "border-gray-300", "transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
isLoading ? "opacity-50" : "", "dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
passwordError
? "border-[#f06a6f] focus:border-[#e5484d]"
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
isLoading || disabled ? "opacity-50" : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
@@ -139,7 +159,11 @@ export function LoginForm({
aria-describedby={passwordError ? "login-password-error" : undefined} aria-describedby={passwordError ? "login-password-error" : undefined}
/> />
{passwordError && ( {passwordError && (
<p id="login-password-error" className="mt-1 text-sm text-blue-600" role="alert"> <p
id="login-password-error"
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
role="alert"
>
{passwordError} {passwordError}
</p> </p>
)} )}
@@ -147,13 +171,13 @@ export function LoginForm({
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading || disabled}
className={[ className={[
"w-full inline-flex items-center justify-center gap-2", "w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white",
"rounded-md px-4 py-2 text-base font-medium", "bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)]",
"bg-blue-600 text-white hover:bg-blue-700", "transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500", "hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]",
isLoading ? "opacity-50 pointer-events-none" : "", isLoading || disabled ? "opacity-50 pointer-events-none" : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}

View File

@@ -13,10 +13,12 @@ export interface OAuthButtonProps {
export function OAuthButton({ export function OAuthButton({
providerName, providerName,
providerId,
onClick, onClick,
isLoading = false, isLoading = false,
disabled = false, disabled = false,
}: OAuthButtonProps): ReactElement { }: OAuthButtonProps): ReactElement {
const accentColor = resolveProviderAccent(providerId);
const isDisabled = disabled || isLoading; const isDisabled = disabled || isLoading;
return ( return (
@@ -27,10 +29,12 @@ export function OAuthButton({
disabled={isDisabled} disabled={isDisabled}
aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`} aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`}
className={[ className={[
"w-full inline-flex items-center justify-center gap-2", "w-full inline-flex items-center justify-center gap-2 rounded-lg",
"rounded-md px-4 py-2 text-base font-medium", "border border-[#b8c4de] bg-[#f8faff]/90 px-4 py-3 text-sm font-semibold text-[#2f3b52]",
"bg-blue-600 text-white hover:bg-blue-700", "transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500", "hover:border-[#2f80ff] hover:bg-[#dde4f2] hover:text-[#0f141d]",
"dark:border-[#2f3b52] dark:bg-[#0f141d]/75 dark:text-[#c5d0e6]",
"dark:hover:border-[#2f80ff] dark:hover:bg-[#232d3f] dark:hover:text-[#eef3ff]",
isDisabled ? "opacity-50 pointer-events-none" : "", isDisabled ? "opacity-50 pointer-events-none" : "",
] ]
.filter(Boolean) .filter(Boolean)
@@ -42,8 +46,33 @@ export function OAuthButton({
<span>Connecting...</span> <span>Connecting...</span>
</> </>
) : ( ) : (
<span>Continue with {providerName}</span> <>
<span
aria-hidden="true"
className="h-2 w-2 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span>Continue with {providerName}</span>
</>
)} )}
</button> </button>
); );
} }
function resolveProviderAccent(providerId: string): string {
const normalized = providerId.toLowerCase();
if (normalized.includes("github")) {
return "#8b5cf6";
}
if (normalized.includes("google")) {
return "#e5484d";
}
if (normalized.includes("ldap")) {
return "#14b8a6";
}
return "#2f80ff";
}

View File

@@ -4,6 +4,11 @@
* This module provides a single source of truth for all API endpoints and URLs. * This module provides a single source of truth for all API endpoints and URLs.
* All components should import from here instead of reading environment variables directly. * All components should import from here instead of reading environment variables directly.
* *
* Runtime config injection:
* - In production containers, NEXT_PUBLIC_* vars are baked at build time and cannot
* be overridden via Docker env vars. The root layout injects runtime values into
* `window.__MOSAIC_ENV__` via a synchronous <script>, which this module reads first.
*
* Environment Variables: * Environment Variables:
* - NEXT_PUBLIC_API_URL: The main API server URL (default: http://localhost:3001) * - NEXT_PUBLIC_API_URL: The main API server URL (default: http://localhost:3001)
* - NEXT_PUBLIC_ORCHESTRATOR_URL: The orchestrator service URL (default: same as API URL) * - NEXT_PUBLIC_ORCHESTRATOR_URL: The orchestrator service URL (default: same as API URL)
@@ -11,6 +16,24 @@
* - If unset: development defaults to `mock`, production defaults to `real` * - If unset: development defaults to `mock`, production defaults to `real`
*/ */
/**
* Read an env variable, preferring runtime-injected values on the client.
*
* Execution order guarantees this works:
* 1. Root layout emits `<script>window.__MOSAIC_ENV__={…}</script>` synchronously.
* 2. Next.js hydrates, loading client modules that call this helper.
*/
function getEnv(name: string): string | undefined {
if (typeof window !== "undefined") {
const w = window as Window & { __MOSAIC_ENV__?: Record<string, string> };
if (w.__MOSAIC_ENV__?.[name]) {
return w.__MOSAIC_ENV__[name];
}
}
// Server-side or build-time fallback
return process.env[name];
}
/** /**
* Default API server URL for local development * Default API server URL for local development
*/ */
@@ -25,10 +48,10 @@ export type AuthMode = (typeof VALID_AUTH_MODES)[number];
* Main API server URL * Main API server URL
* Used for authentication, tasks, events, knowledge, and all core API calls * Used for authentication, tasks, events, knowledge, and all core API calls
*/ */
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? DEFAULT_API_URL; export const API_BASE_URL = getEnv("NEXT_PUBLIC_API_URL") ?? DEFAULT_API_URL;
function resolveAuthMode(): AuthMode { function resolveAuthMode(): AuthMode {
const rawMode = (process.env.NEXT_PUBLIC_AUTH_MODE ?? DEFAULT_AUTH_MODE).toLowerCase(); const rawMode = (getEnv("NEXT_PUBLIC_AUTH_MODE") ?? DEFAULT_AUTH_MODE).toLowerCase();
if (!VALID_AUTH_MODES.includes(rawMode as AuthMode)) { if (!VALID_AUTH_MODES.includes(rawMode as AuthMode)) {
throw new Error( throw new Error(
@@ -60,7 +83,7 @@ export const IS_MOCK_AUTH_MODE = AUTH_MODE === "mock";
* Used for agent management, task progress, and orchestration features * Used for agent management, task progress, and orchestration features
* Falls back to main API URL if not specified (they may run on the same server) * Falls back to main API URL if not specified (they may run on the same server)
*/ */
export const ORCHESTRATOR_URL = process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? API_BASE_URL; export const ORCHESTRATOR_URL = getEnv("NEXT_PUBLIC_ORCHESTRATOR_URL") ?? API_BASE_URL;
/** /**
* Build a full API endpoint URL * Build a full API endpoint URL

View File

@@ -0,0 +1,165 @@
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
function joinClassNames(...classNames: (string | undefined)[]): string {
return classNames.filter(Boolean).join(" ");
}
export interface AuthShellProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
}
export function AuthShell({ children, className, ...props }: AuthShellProps): ReactElement {
return (
<main
className={joinClassNames(
"relative isolate flex min-h-screen items-center justify-center overflow-hidden bg-[#f0f4fc] px-4 py-8 text-[#0f141d] sm:px-8 dark:bg-[#080b12] dark:text-[#eef3ff]",
className
)}
{...props}
>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute left-1/2 top-1/2 h-[52rem] w-[52rem] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-20 blur-[2px] animate-spin"
style={{
background:
"conic-gradient(from 0deg, #2f80ff, #8b5cf6, #ec4899, #e5484d, #f59e0b, #14b8a6, #2f80ff)",
animationDuration: "30s",
}}
/>
<div
className="absolute left-1/2 top-1/2 h-[36rem] w-[36rem] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-20 blur-[1px] animate-spin"
style={{
background:
"conic-gradient(from 120deg, #14b8a6, #06b6d4, #2f80ff, #6366f1, #8b5cf6, #14b8a6)",
animationDuration: "20s",
animationDirection: "reverse",
}}
/>
<div
className="absolute left-1/2 top-1/2 h-[22rem] w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-25 animate-spin"
style={{
background:
"conic-gradient(from 240deg, #f59e0b, #f97316, #e5484d, #ec4899, #8b5cf6, #f59e0b)",
animationDuration: "14s",
}}
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(248,250,255,0.58)_0%,rgba(15,20,29,0.72)_62%,rgba(15,20,29,0.9)_100%)] dark:bg-[radial-gradient(ellipse_at_center,rgba(8,11,18,0.32)_0%,rgba(8,11,18,0.78)_62%,rgba(8,11,18,0.96)_100%)]" />
</div>
<div className="relative z-10 w-full max-w-[27rem]">{children}</div>
</main>
);
}
export interface AuthCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export function AuthCard({ children, className, ...props }: AuthCardProps): ReactElement {
return (
<div
className={joinClassNames(
"relative w-full rounded-2xl border border-[#b8c4de] bg-[#dde4f2]/90 p-6 shadow-[0_30px_70px_rgba(15,20,29,0.24)] backdrop-blur-sm sm:p-10 dark:border-[#2f3b52] dark:bg-[#1b2331]/92 dark:shadow-[0_32px_80px_rgba(0,0,0,0.52)]",
className
)}
{...props}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-0.5 rounded-t-2xl bg-[linear-gradient(90deg,#2f80ff,#8b5cf6,#14b8a6,#f59e0b)]"
/>
{children}
</div>
);
}
export interface AuthBrandProps {
title?: string;
className?: string;
}
export function AuthBrand({ title = "Mosaic Stack", className }: AuthBrandProps): ReactElement {
return (
<div className={joinClassNames("flex items-center justify-center gap-3", className)}>
<div className="relative h-9 w-9 animate-spin" style={{ animationDuration: "20s" }}>
<span className="absolute left-0 top-0 h-[0.88rem] w-[0.88rem] rounded-[3px] bg-[#2f80ff]" />
<span className="absolute right-0 top-0 h-[0.88rem] w-[0.88rem] rounded-[3px] bg-[#8b5cf6]" />
<span className="absolute bottom-0 right-0 h-[0.88rem] w-[0.88rem] rounded-[3px] bg-[#14b8a6]" />
<span className="absolute bottom-0 left-0 h-[0.88rem] w-[0.88rem] rounded-[3px] bg-[#f59e0b]" />
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#ec4899]" />
</div>
<span className="bg-[linear-gradient(135deg,#56a0ff,#8b5cf6,#14b8a6)] bg-clip-text text-xl font-extrabold tracking-tight text-transparent">
{title}
</span>
</div>
);
}
export type AuthStatusTone = "neutral" | "info" | "success" | "warning" | "danger";
export interface AuthStatusPillProps {
label: string;
tone?: AuthStatusTone;
className?: string;
}
export function AuthStatusPill({
label,
tone = "neutral",
className,
}: AuthStatusPillProps): ReactElement {
const toneStyles: Record<AuthStatusTone, string> = {
neutral:
"border-[#b8c4de] bg-[#f8faff] text-[#2f3b52] dark:border-[#2f3b52] dark:bg-[#0f141d]/70 dark:text-[#c5d0e6]",
info: "border-sky-400/50 bg-sky-500/15 text-sky-900 dark:text-sky-200",
success: "border-emerald-400/55 bg-emerald-500/15 text-emerald-900 dark:text-emerald-200",
warning: "border-amber-400/60 bg-amber-500/15 text-amber-900 dark:text-amber-200",
danger: "border-rose-400/55 bg-rose-500/15 text-rose-900 dark:text-rose-200",
};
const dotStyles: Record<AuthStatusTone, string> = {
neutral: "bg-[#5a6a87] dark:bg-[#8f9db7]",
info: "bg-sky-500",
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-rose-500",
};
return (
<span
className={joinClassNames(
"inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[0.67rem] font-semibold uppercase tracking-[0.08em]",
toneStyles[tone],
className
)}
>
<span
aria-hidden="true"
className={joinClassNames("h-1.5 w-1.5 rounded-full", dotStyles[tone])}
/>
<span>{label}</span>
</span>
);
}
export interface AuthDividerProps {
text?: string;
className?: string;
}
export function AuthDivider({
text = "or continue with",
className,
}: AuthDividerProps): ReactElement {
return (
<div
className={joinClassNames(
"my-5 flex items-center gap-3 text-[0.67rem] font-semibold uppercase tracking-[0.08em] text-[#5a6a87] dark:text-[#8f9db7]",
className
)}
>
<span aria-hidden="true" className="h-px flex-1 bg-[#b8c4de] dark:bg-[#2f3b52]" />
<span>{text}</span>
<span aria-hidden="true" className="h-px flex-1 bg-[#b8c4de] dark:bg-[#2f3b52]" />
</div>
);
}

View File

@@ -43,3 +43,20 @@ export type {
ToastContextValue, ToastContextValue,
ToastProviderProps, ToastProviderProps,
} from "./components/Toast.js"; } from "./components/Toast.js";
// Auth Surface
export {
AuthShell,
AuthCard,
AuthBrand,
AuthStatusPill,
AuthDivider,
} from "./components/AuthSurface.js";
export type {
AuthShellProps,
AuthCardProps,
AuthBrandProps,
AuthStatusTone,
AuthStatusPillProps,
AuthDividerProps,
} from "./components/AuthSurface.js";