Some checks failed
ci/woodpecker/push/web Pipeline failed
Maps error codes to PDA-friendly messages (no alarming language). Dismissible error banner with URL param cleanup. Refs #416 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
197 lines
6.8 KiB
TypeScript
197 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import type { ReactElement } from "react";
|
|
import { useRouter, useSearchParams } 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" }],
|
|
};
|
|
|
|
/** Maps URL error codes to PDA-friendly messages (no alarming language). */
|
|
const ERROR_CODE_MESSAGES: Record<string, string> = {
|
|
access_denied: "Authentication paused. Please try again when ready.",
|
|
invalid_credentials: "The email and password combination wasn't recognized.",
|
|
server_error: "The service is taking a break. Please try again in a moment.",
|
|
network_error: "Unable to connect. Check your network and try again.",
|
|
rate_limited: "You've tried a few times. Take a moment and try again shortly.",
|
|
session_expired: "Your session ended. Please sign in again when ready.",
|
|
};
|
|
|
|
const DEFAULT_ERROR_MESSAGE = "Authentication didn't complete. Please try again when ready.";
|
|
|
|
function mapErrorCodeToMessage(code: string): string {
|
|
return ERROR_CODE_MESSAGES[code] ?? DEFAULT_ERROR_MESSAGE;
|
|
}
|
|
|
|
export default function LoginPage(): ReactElement {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
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);
|
|
const [urlError, setUrlError] = useState<string | null>(null);
|
|
|
|
/* Read ?error= query param on mount and map to PDA-friendly message */
|
|
useEffect(() => {
|
|
const errorCode = searchParams.get("error");
|
|
if (errorCode) {
|
|
setUrlError(mapErrorCodeToMessage(errorCode));
|
|
// Clean up the URL by removing the error param without triggering navigation
|
|
const nextParams = new URLSearchParams(searchParams.toString());
|
|
nextParams.delete("error");
|
|
const query = nextParams.toString();
|
|
router.replace(query ? `/login?${query}` : "/login");
|
|
}
|
|
}, [searchParams, router]);
|
|
|
|
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">
|
|
<div className="text-center">
|
|
<h1 className="text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
|
<p className="text-lg text-gray-600">
|
|
Your personal assistant platform. Organize tasks, events, and projects with a
|
|
PDA-friendly approach.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-white p-8 rounded-lg shadow-md">
|
|
{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>
|
|
) : (
|
|
<>
|
|
{urlError && (
|
|
<AuthErrorBanner
|
|
message={urlError}
|
|
onDismiss={(): void => {
|
|
setUrlError(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|