"use client"; import { useState, useEffect, useRef } from "react"; import { useRouter } from "@/i18n/navigation"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuthStore } from "@/stores/auth-store"; import { useThemeStore } from "@/stores/theme-store"; import { useConfig } from "@/hooks/use-config"; import { cn } from "@/lib/utils"; import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; const APP_VERSION = "1.1.2"; export default function LoginPage() { const router = useRouter(); const t = useTranslations("login"); const params = useParams(); const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(); const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, isLoading: configLoading, error: configError } = useConfig(); const [formData, setFormData] = useState({ username: "", password: "", }); const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); const [rememberMe, setRememberMe] = useState(false); const [sessionExpired, setSessionExpired] = useState(false); const [showPassword, setShowPassword] = useState(false); const [shakeError, setShakeError] = useState(false); const [savedUsernames, setSavedUsernames] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const [filteredSuggestions, setFilteredSuggestions] = useState([]); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [oauthMetadata, setOauthMetadata] = useState(null); const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false); const [oauthLoading, setOauthLoading] = useState(false); const suggestionsRef = useRef(null); const inputRef = useRef(null); const justSelectedSuggestion = useRef(false); const totpInputRef = useRef(null); const prevError = useRef(null); useEffect(() => { initializeTheme(); }, [initializeTheme]); useEffect(() => { if (serverUrl) { document.title = appName; } }, [appName, serverUrl]); useEffect(() => { try { if (sessionStorage.getItem('session_expired') === 'true') { setSessionExpired(true); sessionStorage.removeItem('session_expired'); } } catch { /* sessionStorage unavailable */ } }, []); useEffect(() => { if (error && error !== prevError.current) { setShakeError(true); const timer = setTimeout(() => setShakeError(false), 400); return () => clearTimeout(timer); } prevError.current = error; }, [error]); useEffect(() => { if (!serverUrl) return; const saved = localStorage.getItem("webmail_usernames"); if (saved) { try { const usernames = JSON.parse(saved); setSavedUsernames(usernames); } catch { console.error("Failed to parse saved usernames"); } } }, [serverUrl]); useEffect(() => { if (isAuthenticated) { let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { sessionStorage.removeItem('redirect_after_login'); redirectTo = saved; } } catch { /* ignore */ } router.push(redirectTo); } }, [isAuthenticated, router]); useEffect(() => { clearError(); }, [formData, clearError]); useEffect(() => { if (!serverUrl) return; if (justSelectedSuggestion.current) { justSelectedSuggestion.current = false; return; } if (formData.username && savedUsernames.length > 0) { const filtered = savedUsernames.filter(username => username.toLowerCase().includes(formData.username.toLowerCase()) ); setFilteredSuggestions(filtered); setShowSuggestions(filtered.length > 0); } else if (formData.username === "" && savedUsernames.length > 0) { setFilteredSuggestions(savedUsernames); setShowSuggestions(false); } else { setShowSuggestions(false); } setSelectedSuggestionIndex(-1); }, [formData.username, savedUsernames, serverUrl]); useEffect(() => { if (!serverUrl) return; const handleClickOutside = (event: MouseEvent) => { if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) && inputRef.current && !inputRef.current.contains(event.target as Node)) { setShowSuggestions(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [serverUrl]); useEffect(() => { if (!oauthEnabled || !serverUrl) return; discoverOAuth(oauthIssuerUrl || serverUrl) .then((metadata) => { setOauthMetadata(metadata); setOauthDiscoveryDone(true); }) .catch(() => { setOauthMetadata(null); setOauthDiscoveryDone(true); }); }, [oauthEnabled, serverUrl, oauthIssuerUrl]); if (configLoading) { return (
{t("loading")}
); } if (configError) { return (

{t("config_error.title")}

{t("config_error.fetch_failed")}

); } if (!serverUrl) { return (

{t("config_error.title")}

{t("config_error.server_not_configured")}

); } const saveUsername = (username: string) => { const saved = localStorage.getItem("webmail_usernames"); let usernames: string[] = []; if (saved) { try { usernames = JSON.parse(saved); } catch { console.error("Failed to parse saved usernames"); } } if (!usernames.includes(username)) { usernames = [username, ...usernames].slice(0, 5); localStorage.setItem("webmail_usernames", JSON.stringify(usernames)); setSavedUsernames(usernames); } }; const removeUsername = (username: string, e: React.MouseEvent) => { e.stopPropagation(); const updated = savedUsernames.filter(u => u !== username); localStorage.setItem("webmail_usernames", JSON.stringify(updated)); setSavedUsernames(updated); setFilteredSuggestions(updated.filter(u => u.toLowerCase().includes(formData.username.toLowerCase()) )); }; const handleUsernameChange = (e: React.ChangeEvent) => { setFormData({ ...formData, username: e.target.value }); }; const handleUsernameFocus = () => { if (savedUsernames.length > 0 && formData.username === "") { setFilteredSuggestions(savedUsernames); setShowSuggestions(true); } else if (filteredSuggestions.length > 0) { setShowSuggestions(true); } }; const selectSuggestion = (username: string) => { justSelectedSuggestion.current = true; setFormData({ ...formData, username }); setShowSuggestions(false); document.getElementById("password")?.focus(); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!showSuggestions || filteredSuggestions.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestionIndex(prev => prev < filteredSuggestions.length - 1 ? prev + 1 : prev ); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestionIndex(prev => prev > 0 ? prev - 1 : -1); } else if (e.key === "Enter" && selectedSuggestionIndex >= 0) { e.preventDefault(); selectSuggestion(filteredSuggestions[selectedSuggestionIndex]); } else if (e.key === "Escape") { setShowSuggestions(false); setSelectedSuggestionIndex(-1); } }; const handleOAuthLogin = async () => { if (!oauthMetadata || !oauthClientId) return; setOauthLoading(true); const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = generateState(); const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_server_url", serverUrl!); const authUrl = new URL(oauthMetadata.authorization_endpoint); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", oauthClientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", OAUTH_SCOPES); authUrl.searchParams.set("state", state); authUrl.searchParams.set("code_challenge", challenge); authUrl.searchParams.set("code_challenge_method", "S256"); window.location.href = authUrl.toString(); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const success = await login( serverUrl, formData.username, formData.password, totpCode || undefined, rememberMe ); if (success) { saveUsername(formData.username); let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { sessionStorage.removeItem('redirect_after_login'); redirectTo = saved; } } catch { /* ignore */ } router.push(redirectTo); } }; const handleDevLogin = async () => { const success = await login(serverUrl, "dev@localhost", "dev"); if (success) { let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { sessionStorage.removeItem('redirect_after_login'); redirectTo = saved; } } catch { /* ignore */ } router.push(redirectTo); } }; const themeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor; const ThemeIcon = themeIcon; return (
{/* Theme toggle - top right */}
{/* Logo */}

{appName}

{/* Session Expired Banner */} {sessionExpired && (

{t("session_expired")}

)} {/* Error Message */} {error && (

{error === 'invalid_credentials' && showTotpField && totpCode ? t('error.totp_invalid') : t(`error.${error}`) || t("error.generic")}

)} {/* Dev Mode: One-click login */} {devMode ? (

Dev mode — logging in as dev@localhost

) : ( /* Login Form */
{/* Custom autocomplete dropdown */} {showSuggestions && filteredSuggestions.length > 0 && (
{filteredSuggestions.map((username, index) => (
selectSuggestion(username)} > {username}
))}
)}
setFormData({ ...formData, password: e.target.value })} className="h-12 px-4 pr-11 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors" placeholder={t("password_placeholder")} required autoComplete="current-password" />
{!showTotpField ? ( ) : ( setTotpCode(e.target.value.replace(/\D/g, ''))} className="h-10 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono tracking-widest" placeholder={t("totp_placeholder")} autoComplete="one-time-code" aria-label={t("totp_label")} /> )} {rememberMeEnabled && ( )}
{oauthMetadata && ( <>
{t("or")}
)} {oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (

{t("error.oauth_discovery_failed")}

)}
)}
{/* Version number - bottom center */}
v{APP_VERSION}
); }