From 0f58599d77c7ea670f266bf3f15aefc793557f37 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 21 Feb 2026 17:12:03 -0600 Subject: [PATCH] fix(web): restore login page design and add runtime config injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login page was deployed with the old unstyled version on main — the Round 5 design system components (AuthShell, AuthCard, AuthBrand) had never been merged. This commit restores the designed login experience with animated gradient background, card chrome, and proper dark-mode support matching the design reference. Additionally, NEXT_PUBLIC_API_URL was hardcoded at build time via the CI --build-arg, making it impossible to override in container env vars. The root layout now injects runtime env vars into window.__MOSAIC_ENV__ via a synchronous script tag, and config.ts reads those values first. This lets deployed containers use their own API URL without rebuilding. Changes: - Add AuthSurface.tsx to @mosaic/ui (AuthShell, AuthCard, AuthBrand, AuthStatusPill, AuthDivider) - Rewrite login page to use design system components with "Command Center" heading matching the design reference - Update config.ts with getEnv() helper that reads window.__MOSAIC_ENV__ - Add runtime env injection script to root layout.tsx - Update all tests to match new component structure and content Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/(auth)/login/page.test.tsx | 36 ++-- apps/web/src/app/(auth)/login/page.tsx | 144 ++++++++------- apps/web/src/app/layout.tsx | 23 +++ .../src/components/auth/AuthDivider.test.tsx | 8 +- apps/web/src/components/auth/AuthDivider.tsx | 20 +-- .../components/auth/AuthErrorBanner.test.tsx | 17 +- .../src/components/auth/AuthErrorBanner.tsx | 4 +- apps/web/src/components/auth/LoginForm.tsx | 64 ++++--- apps/web/src/components/auth/OAuthButton.tsx | 39 ++++- apps/web/src/lib/config.ts | 29 ++- packages/ui/src/components/AuthSurface.tsx | 165 ++++++++++++++++++ packages/ui/src/index.ts | 17 ++ 12 files changed, 416 insertions(+), 150 deletions(-) create mode 100644 packages/ui/src/components/AuthSurface.tsx diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index f88ec68..5811ce4 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -127,8 +127,8 @@ describe("LoginPage", (): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); - expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack"); - expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Command Center"); + expect(screen.getByText(/Sign in to your orchestration platform/i)).toBeInTheDocument(); }); it("has proper layout styling", async (): Promise => { @@ -186,7 +186,7 @@ describe("LoginPage", (): void => { 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(/password/i)).toBeInTheDocument(); }); @@ -200,7 +200,11 @@ describe("LoginPage", (): void => { 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 => { @@ -215,7 +219,6 @@ describe("LoginPage", (): void => { // Should NOT silently fall back to email form expect(screen.queryByLabelText(/email/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 expect( @@ -453,7 +456,7 @@ describe("LoginPage", (): void => { /* ------------------------------------------------------------------ */ describe("responsive layout", (): void => { - it("applies mobile-first padding to main element", async (): Promise => { + it("applies AuthShell layout classes to main element", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); const { container } = render(); @@ -463,8 +466,7 @@ describe("LoginPage", (): void => { }); const main = container.querySelector("main"); - - expect(main).toHaveClass("p-4", "sm:p-8"); + expect(main).toHaveClass("min-h-screen", "items-center", "justify-center"); }); it("applies responsive text size to heading", async (): Promise => { @@ -477,10 +479,10 @@ describe("LoginPage", (): void => { }); 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 => { + it("AuthCard applies card styling with padding", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); const { container } = render(); @@ -489,12 +491,12 @@ describe("LoginPage", (): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); - const card = container.querySelector(".bg-white"); - - expect(card).toHaveClass("p-4", "sm:p-8"); + // AuthCard uses rounded-2xl and p-6 sm:p-10 + const card = container.querySelector(".rounded-2xl"); + expect(card).toHaveClass("p-6", "sm:p-10"); }); - it("card container has full width with max-width constraint", async (): Promise => { + it("AuthShell constrains card width", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); const { container } = render(); @@ -503,9 +505,9 @@ describe("LoginPage", (): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); - const wrapper = container.querySelector(".max-w-md"); - - expect(wrapper).toHaveClass("w-full", "max-w-md"); + // AuthShell wraps children in max-w-[27rem] + const wrapper = container.querySelector(".max-w-\\[27rem\\]"); + expect(wrapper).toHaveClass("w-full"); }); }); diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 4a58376..388c5e1 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -5,6 +5,7 @@ import type { ReactElement } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Loader2 } from "lucide-react"; 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 { signIn } from "@/lib/auth-client"; import { fetchWithRetry } from "@/lib/auth/fetch-with-retry"; @@ -19,23 +20,21 @@ export default function LoginPage(): ReactElement { return ( -
-
-

Welcome to Mosaic Stack

-
-
+ + +
+
-
-
- + + } > @@ -185,47 +184,51 @@ function LoginPageContent(): ReactElement { if (IS_MOCK_AUTH_MODE) { return ( -
-
-
-

Welcome to Mosaic Stack

-

- Local mock auth mode is active. Real sign-in is bypassed for frontend development. -

-
-
-
- Mock auth mode is local-only and blocked outside development. + + +
+ +
+

Command Center

+

+ Local mock auth mode is active +

+
+ +
+ {error && }
-
-
+ + ); } return ( -
-
-
-

Welcome to Mosaic Stack

-

- Your personal assistant platform. Organize tasks, events, and projects with a - PDA-friendly approach. -

+ + +
+ +
+

Command Center

+

+ Sign in to your orchestration platform +

+
-
+
{loadingConfig ? (
-
) : config === null ? ( @@ -243,47 +246,35 @@ function LoginPageContent(): ReactElement {
) : ( - <> +
{urlError && ( - { - setUrlError(null); - }} - /> +
+ { + setUrlError(null); + }} + /> +
)} {error && !hasCredentials && ( - { - setError(null); - }} - /> - )} - - {hasOAuth && - oauthProviders.map((provider) => ( - { - handleOAuthLogin(provider.id); +
+ { + setError(null); }} - isLoading={oauthLoading === provider.id} - disabled={oauthLoading !== null && oauthLoading !== provider.id} /> - ))} - - {hasOAuth && hasCredentials && } +
+ )} {hasCredentials && ( )} - + + {hasOAuth && hasCredentials && } + + {hasOAuth && ( +
+ {oauthProviders.map((provider) => ( + { + handleOAuthLogin(provider.id); + }} + isLoading={oauthLoading === provider.id} + disabled={oauthLoading !== null && oauthLoading !== provider.id} + /> + ))} +
+ )} +
)}
-
-
+ +
+ +
+ + ); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9db0ecf..8f625ad 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -10,9 +10,32 @@ export const metadata: Metadata = { 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 = {}; + 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 { return ( + + ` 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 }; + 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 */ @@ -25,10 +48,10 @@ export type AuthMode = (typeof VALID_AUTH_MODES)[number]; * Main API server URL * 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 { - 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)) { 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 * 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 diff --git a/packages/ui/src/components/AuthSurface.tsx b/packages/ui/src/components/AuthSurface.tsx new file mode 100644 index 0000000..07a2ae8 --- /dev/null +++ b/packages/ui/src/components/AuthSurface.tsx @@ -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 { + children: ReactNode; +} + +export function AuthShell({ children, className, ...props }: AuthShellProps): ReactElement { + return ( +
+
+ ); +} + +export interface AuthCardProps extends HTMLAttributes { + children: ReactNode; +} + +export function AuthCard({ children, className, ...props }: AuthCardProps): ReactElement { + return ( +
+ + ); +} + +export interface AuthBrandProps { + title?: string; + className?: string; +} + +export function AuthBrand({ title = "Mosaic Stack", className }: AuthBrandProps): ReactElement { + return ( +
+
+ + + + + +
+ + {title} + +
+ ); +} + +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 = { + 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 = { + neutral: "bg-[#5a6a87] dark:bg-[#8f9db7]", + info: "bg-sky-500", + success: "bg-emerald-500", + warning: "bg-amber-500", + danger: "bg-rose-500", + }; + + return ( + + + ); +} + +export interface AuthDividerProps { + text?: string; + className?: string; +} + +export function AuthDivider({ + text = "or continue with", + className, +}: AuthDividerProps): ReactElement { + return ( +
+
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2b2ebc8..6b00a83 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -43,3 +43,20 @@ export type { ToastContextValue, ToastProviderProps, } 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"; -- 2.49.1