import { ArrowLeft, ArrowRight, Check, EnvelopeSimple, Key, LockKey, ShieldCheck, User, } from "@phosphor-icons/react"; import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react"; import { Button } from "../button"; import { Card, CardBody, CardHeader } from "../card"; import { Alert } from "../feedback"; import { Center, Column, Row } from "../layout"; import { OtpInput } from "../inputs/otp-input"; import { DatePicker } from "../inputs/date-picker"; import { SegmentedControl } from "../selection/segmented-control"; import { PasswordInput } from "../inputs/password-input"; import { TextField } from "../text-field"; import { Text } from "../typography"; import { UhuruSignInWithDigitwhale } from "./uhuru-sign-in-with-digitwhale"; export type AuthPageFrameProps = { animation?: AuthAnimation; children: ReactNode; description?: string; eyebrow?: string; footer?: ReactNode; title: string; variant?: AuthPageVariant; }; export type AuthAnimation = "none" | "fade" | "slide-up" | "scale"; export type AuthPageVariant = "default" | "minimal" | "quiet"; export type AuthPageFrameOptions = Omit, "children">; export function AuthPageFrame({ children, animation = "fade", description, eyebrow = "Digitwhale Innovations", footer, title, variant = "default", }: AuthPageFrameProps) { return (
{children} {footer ?
{footer}
: null}
); } function AuthDivider({ label = "Or continue with" }: { label?: string }) { return (
{label}
); } function AuthLink({ children, onClick }: { children: ReactNode; onClick?: () => void }) { return ( ); } export type LoginPageProps = { defaultEmail?: string; error?: string; frame?: AuthPageFrameOptions; loading?: boolean; onCreateAccount?: () => void; onDigitwhale?: () => void; onForgotPassword?: (email: string) => void; onPasskey?: () => void; onSubmit?: (data: { email: string; password: string }) => void; }; export function LoginPage({ defaultEmail = "", error, frame, loading = false, onCreateAccount, onDigitwhale, onForgotPassword, onPasskey, onSubmit, }: LoginPageProps) { const [email, setEmail] = useState(defaultEmail); const [password, setPassword] = useState(""); function submit(event: FormEvent) { event.preventDefault(); onSubmit?.({ email, password }); } return ( New to Uhuru? Create an account } >
{error ? {error} : null} setEmail(event.target.value)} placeholder="you@example.com" prefixIcon={
); } export type ForgotPasswordPageProps = { defaultEmail?: string; error?: string; frame?: AuthPageFrameOptions; loading?: boolean; onBack?: () => void; onSubmit?: (email: string) => void; submitted?: boolean; }; export function ForgotPasswordPage({ defaultEmail = "", error, frame, loading = false, onBack, onSubmit, submitted = false, }: ForgotPasswordPageProps) { const [email, setEmail] = useState(defaultEmail); return ( {error ? {error} : null} {submitted ? ( If an account exists for {email}, a password reset link is on its way. ) : (
{ event.preventDefault(); onSubmit?.(email); }}> setEmail(event.target.value)} placeholder="you@example.com" prefixIcon={
); } export type OtpVerificationPageProps = { channel?: string; error?: string; frame?: AuthPageFrameOptions; length?: number; loading?: boolean; onBack?: () => void; onResend?: () => void; onSubmit?: (code: string) => void; value?: string; }; export function OtpVerificationPage({ channel = "your email address", error, frame, length = 6, loading = false, onBack, onResend, onSubmit, value: controlledValue, }: OtpVerificationPageProps) { const [internalValue, setInternalValue] = useState(controlledValue ?? ""); const value = controlledValue ?? internalValue; return ( {error ? {error} : null} Did not receive it? Resend code ); } export type PasskeyPageProps = { error?: string; frame?: AuthPageFrameOptions; loading?: boolean; onBack?: () => void; onUsePassword?: () => void; onSubmit?: () => void; }; export function PasskeyPage({ error, frame, loading = false, onBack, onSubmit, onUsePassword }: PasskeyPageProps) { return (
Fast, private, and phishing-resistant Your biometric or device PIN stays on your device.
{error ? {error} : null} Prefer a password? Sign in another way
); } export type AccountCreationData = { dateOfBirth: string; email: string; firstName: string; lastName: string; password: string; phone: string; }; export type CreateAccountPageProps = { defaultValues?: Partial; error?: string; frame?: AuthPageFrameOptions; loading?: boolean; onBack?: () => void; onSubmit?: (data: AccountCreationData) => void; }; const accountSteps = ["Your name", "Account details", "Personal details"]; export function CreateAccountPage({ defaultValues, error, frame, loading = false, onBack, onSubmit, }: CreateAccountPageProps) { const [step, setStep] = useState(0); const [data, setData] = useState({ dateOfBirth: defaultValues?.dateOfBirth ?? "", email: defaultValues?.email ?? "", firstName: defaultValues?.firstName ?? "", lastName: defaultValues?.lastName ?? "", password: defaultValues?.password ?? "", phone: defaultValues?.phone ?? "", }); function update(field: keyof AccountCreationData, value: string) { setData((current) => ({ ...current, [field]: value })); } function submit(event: FormEvent) { event.preventDefault(); if (step < accountSteps.length - 1) { setStep((current) => current + 1); return; } onSubmit?.(data); } return ( Already have an account? Sign in } >
{accountSteps.map((label, index) => (
{label}
))}
{error ? {error} : null}
{step === 0 ? ( update("firstName", event.target.value)} prefixIcon={ ) : null} {step === 1 ? ( update("email", event.target.value)} prefixIcon={ ) : null} {step === 2 ? ( update("dateOfBirth", event.target.value)} required value={data.dateOfBirth} /> update("phone", event.target.value)} placeholder="+1 555 123 4567" required type="tel" value={data.phone} /> ) : null}
); } export type AuthTwoFactorMethod = "recovery-key" | "authenticator" | "email-otp"; export type TwoFactorPageProps = { availableMethods?: AuthTwoFactorMethod[]; channel?: string; defaultMethod?: AuthTwoFactorMethod; error?: string; frame?: AuthPageFrameOptions; loading?: boolean; method?: AuthTwoFactorMethod; onBack?: () => void; onMethodChange?: (method: AuthTwoFactorMethod) => void; onResend?: () => void; onSubmit?: (data: { method: AuthTwoFactorMethod; value: string }) => void; value?: string; }; const defaultTwoFactorMethods: AuthTwoFactorMethod[] = ["authenticator", "email-otp", "recovery-key"]; const twoFactorLabels: Record = { authenticator: "Authenticator", "email-otp": "Email code", "recovery-key": "Recovery key", }; export function TwoFactorPage({ availableMethods = defaultTwoFactorMethods, channel = "your email address", defaultMethod, error, frame, loading = false, method: controlledMethod, onBack, onMethodChange, onResend, onSubmit, value: controlledValue, }: TwoFactorPageProps) { const methods = availableMethods.filter((candidate, index) => availableMethods.indexOf(candidate) === index); const initialMethod = defaultMethod && methods.includes(defaultMethod) ? defaultMethod : methods[0] ?? "authenticator"; const [internalMethod, setInternalMethod] = useState(initialMethod); const [internalValue, setInternalValue] = useState(""); const selectedMethod = controlledMethod && methods.includes(controlledMethod) ? controlledMethod : internalMethod; const value = controlledValue ?? internalValue; const isOtp = selectedMethod === "authenticator" || selectedMethod === "email-otp"; function changeMethod(next: string) { const nextMethod = next as AuthTwoFactorMethod; if (!methods.includes(nextMethod)) return; setInternalMethod(nextMethod); setInternalValue(""); onMethodChange?.(nextMethod); } return ( {error ? {error} : null} {methods.length > 1 ? ( ({ label: twoFactorLabels[candidate], value: candidate }))} onValueChange={changeMethod} value={selectedMethod} /> ) : null} {selectedMethod === "recovery-key" ? ( setInternalValue(event.target.value)} placeholder="Enter your recovery key" prefixIcon={ ); } export type AuthFlowScreen = "login" | "forgot-password" | "otp" | "passkey" | "two-factor" | "create-account"; export type AuthFlowRoutes = Partial>; export const defaultAuthFlowRoutes: Record = { login: "/login", "forgot-password": "/forgot-password", otp: "/verify-code", passkey: "/passkey", "two-factor": "/two-factor", "create-account": "/create-account", }; export const defaultAuthFlowViews: AuthFlowScreen[] = [ "login", "otp", ]; export type AuthFlowNavigation = { back: () => void; go: (screen: AuthFlowScreen) => void; screen: AuthFlowScreen; }; export type AuthFlowPageRenderer = ( screen: AuthFlowScreen, navigation: AuthFlowNavigation, ) => ReactNode | undefined; export type AuthFlowTransition = AuthFlowScreen | void; type AuthFlowLoginProps = Omit; export type AuthFlowProps = AuthFlowLoginProps & { /** The first page shown by the coordinator. `initialScreen` remains supported as an alias. */ initialPage?: AuthFlowScreen; initialScreen?: AuthFlowScreen; /** Enabled routable screens. A route for a disabled screen renders an error. */ views?: AuthFlowScreen[]; /** Compatibility alias for `views`. */ enabledViews?: AuthFlowScreen[]; /** Override the default paths for each auth screen. */ routes?: AuthFlowRoutes; /** Controlled browser/router path. Defaults to window.location.pathname. */ currentPath?: string; /** Called when AuthFlow navigates to a screen. */ onRouteChange?: (path: string, screen: AuthFlowScreen) => void; /** Props for each built-in page. Navigation callbacks are managed by AuthFlow. */ login?: AuthFlowLoginProps; forgotPassword?: Omit; otp?: Omit; passkey?: Omit; twoFactor?: Omit; createAccount?: Omit; /** Shared visual configuration applied to every built-in page. */ frame?: AuthPageFrameOptions; onCreateAccount?: (data: AccountCreationData) => void; onForgotPassword?: (email: string) => void; onLoginSubmit?: (data: { email: string; password: string }, navigation: AuthFlowNavigation) => AuthFlowTransition; onRecoverySubmit?: (email: string, navigation: AuthFlowNavigation) => AuthFlowTransition; onOtpSubmit?: (code: string) => void; onPasskey?: () => void; onTwoFactorSubmit?: (data: { method: AuthTwoFactorMethod; value: string }, navigation: AuthFlowNavigation) => AuthFlowTransition; onScreenChange?: (screen: AuthFlowScreen) => void; /** Return custom UI for a screen, or undefined to use the built-in screen. */ renderPage?: AuthFlowPageRenderer; }; export function AuthRouteError({ message = "This authentication page is not configured for the current route.", onBack, }: { message?: string; onBack?: () => void; }) { return ( {message} {onBack ? : null} ); } function screenForPath(path: string, routes: Record) { return (Object.keys(routes) as AuthFlowScreen[]).find((candidate) => routes[candidate] === path); } export function AuthFlow({ createAccount, forgotPassword, initialPage, initialScreen, frame, currentPath, enabledViews, login, onScreenChange, onRouteChange, otp, passkey, twoFactor, renderPage, routes: routeOverrides, views, ...props }: AuthFlowProps) { const routes = useMemo(() => ({ ...defaultAuthFlowRoutes, ...routeOverrides }), [routeOverrides]); const configuredViews = views ?? enabledViews ?? defaultAuthFlowViews; const initialRoutePath = typeof window === "undefined" ? undefined : window.location.pathname; const routeScreen = screenForPath(currentPath ?? initialRoutePath ?? "", routes); const initialResolvedScreen = routeScreen ?? initialPage ?? initialScreen ?? "login"; const [screen, setScreen] = useState(initialResolvedScreen); const [routeError, setRouteError] = useState(() => { if (currentPath && !routeScreen) return `No authentication view is registered for ${currentPath}.`; if (routeScreen && !configuredViews.includes(routeScreen)) return `The ${routeScreen} view is not enabled in AuthFlow.views.`; if (!configuredViews.includes(initialResolvedScreen)) return `The ${initialResolvedScreen} view is not enabled in AuthFlow.views.`; return undefined; }); useEffect(() => { if (currentPath !== undefined) { const nextScreen = screenForPath(currentPath, routes); if (!nextScreen) { setRouteError(`No authentication view is registered for ${currentPath}.`); } else if (!configuredViews.includes(nextScreen)) { setRouteError(`The ${nextScreen} view is not enabled in AuthFlow.views.`); } else { setRouteError(undefined); setScreen(nextScreen); onScreenChange?.(nextScreen); } return; } function handlePopState() { const nextPath = window.location.pathname; const nextScreen = screenForPath(nextPath, routes); if (!nextScreen) { setRouteError(`No authentication view is registered for ${nextPath}.`); return; } if (!configuredViews.includes(nextScreen)) { setRouteError(`The ${nextScreen} view is not enabled in AuthFlow.views.`); return; } setRouteError(undefined); setScreen(nextScreen); onScreenChange?.(nextScreen); } window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); }, [configuredViews, currentPath, onScreenChange, routes]); const go = (next: AuthFlowScreen) => { if (!configuredViews.includes(next)) { setRouteError(`The ${next} view is not enabled in AuthFlow.views.`); return; } const nextPath = routes[next]; setRouteError(undefined); setScreen(next); if (currentPath === undefined && typeof window !== "undefined" && window.location.pathname !== nextPath) { window.history.pushState({}, "", nextPath); } onRouteChange?.(nextPath, next); onScreenChange?.(next); }; const navigation: AuthFlowNavigation = { back: () => go("login"), go, screen, }; if (routeError) { return go("login") : undefined} />; } if (!configuredViews.includes(screen)) { return go("login") : undefined} />; } const customPage = renderPage?.(screen, navigation); if (customPage !== undefined) { return customPage; } if (screen === "forgot-password") { return ( { (forgotPassword?.onSubmit ?? props.onForgotPassword)?.(email); const next = props.onRecoverySubmit?.(email, navigation); if (next) go(next); }} /> ); } if (screen === "otp") { return ; } if (screen === "passkey") { return ; } if (screen === "two-factor") { return ( { twoFactor?.onSubmit?.(data); const next = props.onTwoFactorSubmit?.(data, navigation); if (next) go(next); }} /> ); } if (screen === "create-account") { return ; } return ( go("create-account")} onForgotPassword={() => go("forgot-password")} onPasskey={() => go("passkey")} onSubmit={(data) => { (login?.onSubmit ?? props.onSubmit)?.(data); const next = props.onLoginSubmit?.(data, navigation); if (next) go(next); }} /> ); }