fix(web): restore login page design and add runtime config injection #435
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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(" ")}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
165
packages/ui/src/components/AuthSurface.tsx
Normal file
165
packages/ui/src/components/AuthSurface.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user