"use client"; import Image from "next/image"; import { ArrowLeft, ArrowRight, Eye, EyeOff, Lock, UserRound, } from "@/components/icons/platform-icons"; import { useRouter, useSearchParams } from "next/navigation"; import { type FormEvent, useEffect, useMemo, useState } from "react"; import type { LoginResponseData, TwoFactorErrorCode, } from "@/lib/shared/contracts/auth"; const RESTORE_BANNER_KEY = "homeio:restore-banner"; const DEFAULT_RETRY_DELAY_SECONDS = 3; const DEMO_USERNAME = "homeio"; const DEMO_PASSWORD = "homeio26"; type LoginStage = | { kind: "credentials" } | { kind: "totp"; partialAuthToken: string; useBackupCode: boolean }; function describeTotpError( code: TwoFactorErrorCode | undefined, fallback: string, ): string { switch (code) { case "invalid_totp": return "That code doesn't match. Check the time on your device and try again."; case "invalid_backup_code": return "That backup code isn't valid or has already been used."; case "partial_auth_expired": return "Your sign-in window expired. Enter your password again."; case "partial_auth_consumed": return "This sign-in attempt was already finalised. Start over."; case "too_many_attempts": return "Too many attempts. Wait a moment before trying again."; default: return fallback; } } export function LoginForm({ isDemoMode = false }: { isDemoMode?: boolean }) { const router = useRouter(); const searchParams = useSearchParams(); const nextPath = useMemo(() => { const requested = searchParams.get("next") ?? "/"; return requested.startsWith("/") ? requested : "/"; }, [searchParams]); const [username, setUsername] = useState( () => isDemoMode ? DEMO_USERNAME : (searchParams.get("username")?.trim().toLowerCase() ?? ""), ); const [password, setPassword] = useState( () => isDemoMode ? DEMO_PASSWORD : "", ); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [showRestoreBanner, setShowRestoreBanner] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [retryCountdown, setRetryCountdown] = useState(0); const [stage, setStage] = useState({ kind: "credentials" }); const [totpCode, setTotpCode] = useState(""); useEffect(() => { try { if (window.localStorage.getItem(RESTORE_BANNER_KEY)) { window.localStorage.removeItem(RESTORE_BANNER_KEY); setShowRestoreBanner(true); } } catch { // Best-effort only. } }, []); useEffect(() => { if (retryCountdown <= 0) return; const timeoutId = window.setTimeout(() => { setRetryCountdown((currentValue) => Math.max(0, currentValue - 1)); }, 1_000); return () => window.clearTimeout(timeoutId); }, [retryCountdown]); async function handleCredentialsSubmit(event: FormEvent) { event.preventDefault(); setError(null); setIsSubmitting(true); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ username, password, }), }); if (!response.ok) { const json = (await response.json().catch(() => ({}))) as { error?: string; retryAfterSeconds?: number; }; const retryAfterHeader = response.headers.get("retry-after"); const parsedRetryAfter = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : NaN; const retryAfterSeconds = Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : json.retryAfterSeconds && json.retryAfterSeconds > 0 ? json.retryAfterSeconds : response.status === 401 ? DEFAULT_RETRY_DELAY_SECONDS : 0; if (retryAfterSeconds > 0) { setRetryCountdown(retryAfterSeconds); } throw new Error(json.error ?? "Login failed"); } const payload = (await response.json().catch(() => ({}))) as { data?: LoginResponseData; }; if (payload.data && "requiresTotp" in payload.data) { setStage({ kind: "totp", partialAuthToken: payload.data.partialAuthToken, useBackupCode: false, }); setTotpCode(""); return; } router.replace(nextPath); router.refresh(); } catch (submitError) { setError( submitError instanceof Error ? submitError.message : "Login failed", ); } finally { setIsSubmitting(false); } } async function handleTotpSubmit(event: FormEvent) { event.preventDefault(); if (stage.kind !== "totp") return; setError(null); setIsSubmitting(true); try { const response = await fetch("/api/v1/auth/login/totp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ partialAuthToken: stage.partialAuthToken, code: totpCode.trim(), }), }); const payload = (await response.json().catch(() => ({}))) as { error?: string; code?: TwoFactorErrorCode; }; if (!response.ok) { if (payload.code === "partial_auth_expired" || payload.code === "partial_auth_consumed") { setStage({ kind: "credentials" }); setPassword(""); } throw new Error(describeTotpError(payload.code, payload.error ?? "Sign-in failed")); } router.replace(nextPath); router.refresh(); } catch (submitError) { setError( submitError instanceof Error ? submitError.message : "Sign-in failed", ); } finally { setIsSubmitting(false); } } function backToCredentials() { setStage({ kind: "credentials" }); setTotpCode(""); setError(null); setPassword(""); } function toggleBackupCode() { if (stage.kind !== "totp") return; setStage({ ...stage, useBackupCode: !stage.useBackupCode }); setTotpCode(""); setError(null); } return (
Homeio
Home server

{stage.kind === "credentials" ? "Welcome back" : "Two-step verification"}

{stage.kind === "credentials" ? "Sign in to continue" : stage.useBackupCode ? "Enter a backup code" : "Enter the code from your app"}

{isDemoMode && stage.kind === "credentials" ? (

Demo mode

Username: {DEMO_USERNAME} {" ยท "}Password: {DEMO_PASSWORD}

) : null} {showRestoreBanner && stage.kind === "credentials" ? (

Restore complete, session reset

Please sign in with the credentials that were active when this backup was taken.

) : null} {stage.kind === "credentials" ? (
setUsername(event.target.value)} placeholder="Username" className="h-10 w-full border-0 bg-transparent px-1 text-[15px] text-foreground outline-none placeholder:text-muted-foreground/52" required autoFocus />
setPassword(event.target.value)} placeholder="Password" className="h-10 w-full border-0 bg-transparent px-1 text-[15px] text-foreground outline-none placeholder:text-muted-foreground/52" required />
{error ? (

{error}

) : null} ) : (
setTotpCode(event.target.value)} placeholder={stage.useBackupCode ? "Backup code" : "123 456"} className="h-10 w-full border-0 bg-transparent px-1 text-[15px] tracking-[0.2em] text-foreground outline-none placeholder:text-muted-foreground/52" autoFocus required />
{error ? (

{error}

) : null}
)}
); }