feat(#416): redesign login page with dynamic provider rendering
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Fetches GET /auth/config on mount and renders OAuth + email/password
forms based on backend-advertised providers. Falls back to email-only
if config fetch fails.

Refs #416

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 11:45:44 -06:00
parent 3ab87362a9
commit 2020c15545
2 changed files with 399 additions and 22 deletions

View File

@@ -1,7 +1,101 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import type { ReactElement } from "react";
import { LoginButton } from "@/components/auth/LoginButton";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
import { API_BASE_URL } from "@/lib/config";
import { signIn } from "@/lib/auth-client";
import { OAuthButton } from "@/components/auth/OAuthButton";
import { LoginForm } from "@/components/auth/LoginForm";
import { AuthDivider } from "@/components/auth/AuthDivider";
import { AuthErrorBanner } from "@/components/auth/AuthErrorBanner";
/** Fallback config when the backend is unreachable */
const EMAIL_ONLY_CONFIG: AuthConfigResponse = {
providers: [{ id: "email", name: "Email", type: "credentials" }],
};
export default function LoginPage(): ReactElement {
const router = useRouter();
const [config, setConfig] = useState<AuthConfigResponse | null>(null);
const [loadingConfig, setLoadingConfig] = useState(true);
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
const [credentialsLoading, setCredentialsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchConfig(): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/auth/config`);
if (!response.ok) {
throw new Error("Failed to fetch auth config");
}
const data = (await response.json()) as AuthConfigResponse;
if (!cancelled) {
setConfig(data);
}
} catch {
if (!cancelled) {
setConfig(EMAIL_ONLY_CONFIG);
}
} finally {
if (!cancelled) {
setLoadingConfig(false);
}
}
}
void fetchConfig();
return (): void => {
cancelled = true;
};
}, []);
const oauthProviders: AuthProviderConfig[] =
config?.providers.filter((p) => p.type === "oauth") ?? [];
const credentialProviders: AuthProviderConfig[] =
config?.providers.filter((p) => p.type === "credentials") ?? [];
const hasOAuth = oauthProviders.length > 0;
const hasCredentials = credentialProviders.length > 0;
const handleOAuthLogin = useCallback((providerId: string): void => {
setOauthLoading(providerId);
setError(null);
void signIn.oauth2({ providerId, callbackURL: "/" });
}, []);
const handleCredentialsLogin = useCallback(
async (email: string, password: string): Promise<void> => {
setCredentialsLoading(true);
setError(null);
try {
const result = await signIn.email({ email, password });
if (result.error) {
setError(
typeof result.error.message === "string"
? result.error.message
: "Unable to sign in. Please check your credentials and try again."
);
} else {
router.push("/tasks");
}
} catch {
setError("Something went wrong. Please try again in a moment.");
} finally {
setCredentialsLoading(false);
}
},
[router]
);
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8 bg-gray-50">
<div className="w-full max-w-md space-y-8">
@@ -12,8 +106,49 @@ export default function LoginPage(): ReactElement {
PDA-friendly approach.
</p>
</div>
<div className="bg-white p-8 rounded-lg shadow-md">
<LoginButton />
{loadingConfig ? (
<div className="flex items-center justify-center py-8" data-testid="loading-spinner">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
<span className="sr-only">Loading authentication options</span>
</div>
) : (
<>
{error && !hasCredentials && (
<AuthErrorBanner
message={error}
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}
/>
))}
{hasOAuth && hasCredentials && <AuthDivider />}
{hasCredentials && (
<LoginForm
onSubmit={handleCredentialsLogin}
isLoading={credentialsLoading}
error={error}
/>
)}
</>
)}
</div>
</div>
</main>