Files
SRCmail/app/[locale]/login/page.tsx
T
Matthieu MALVACHEandMatthieu MALVACHE a43096485b feat: add UI/UX polish with navigation rail, confirm dialogs, welcome banner, and form validation
- Add NavigationRail component (desktop vertical icon sidebar + mobile bottom tab bar)
- Add ConfirmDialog with promise-based useConfirmDialog hook for async confirmation flow
- Add WelcomeBanner onboarding component (one-time display, localStorage persistence)
- Polish login form UX (shake on error, TOTP slide animation, password visibility toggle, session expired banner)
- Add inline form validation with shake animation in email composer and contacts
- Add empty state patterns for contacts (no data vs no search results with contextual actions)
- Improve toast notification system with undo action support and typed durations
- Add WCAG AA prefers-reduced-motion media query, safe area insets, sr-only live regions
- Add template settings tab and keyboard shortcut integration
- Update all 8 locale translations
2026-02-17 02:31:50 +01:00

461 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "@/i18n/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 { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, ShieldCheck, Info, Eye, EyeOff } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [showTotpField, setShowTotpField] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [sessionExpired, setSessionExpired] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const justSelectedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
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) {
router.push('/');
}
}, [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 (showTotpField && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [showTotpField]);
if (configLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto" />
<span className="sr-only">{t("loading")}</span>
</div>
</div>
);
}
if (configError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
<h1 className="text-xl font-medium text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm">
{t("config_error.fetch_failed")}
</p>
</div>
</div>
);
}
if (!serverUrl) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
<h1 className="text-xl font-medium text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm">
{t("config_error.server_not_configured")}
</p>
</div>
</div>
);
}
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const success = await login(
serverUrl,
formData.username,
formData.password,
showTotpField && totpCode ? totpCode : undefined
);
if (success) {
saveUsername(formData.username);
router.push('/');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4">
{/* Logo */}
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-6 shadow-lg shadow-primary/5">
<Mail className="w-10 h-10 text-primary" />
</div>
<h1 className="text-3xl font-light text-foreground tracking-tight">
{appName}
</h1>
</div>
{/* Session Expired Banner */}
{sessionExpired && (
<div
className="mb-6 p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-start gap-3"
role="status"
aria-live="polite"
>
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1">
{t("session_expired")}
</p>
<button
type="button"
onClick={() => setSessionExpired(false)}
className="p-0.5 rounded hover:bg-blue-500/10 transition-colors flex-shrink-0"
aria-label={t("dismiss")}
>
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</button>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">
{error === 'invalid_credentials' && showTotpField
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
{/* Login Form */}
<form
onSubmit={handleSubmit}
className={cn("space-y-4", shakeError && "animate-shake")}
>
<fieldset disabled={isLoading} className="space-y-4">
<div className="relative">
<Input
ref={inputRef}
id="username"
type="text"
value={formData.username}
onChange={handleUsernameChange}
onFocus={handleUsernameFocus}
onKeyDown={handleKeyDown}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("username_placeholder")}
required
autoComplete="off"
data-form-type="other"
data-lpignore="true"
autoFocus
/>
{/* Custom autocomplete dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute top-full mt-1 w-full bg-secondary border border-border rounded-md shadow-lg z-50 overflow-hidden"
>
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={cn(
"px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
index === selectedSuggestionIndex && "bg-muted"
)}
onClick={() => selectSuggestion(username)}
>
<span className="text-sm text-foreground">{username}</span>
<button
type="button"
onClick={(e) => removeUsername(username, e)}
className="p-1 hover:bg-background rounded transition-colors"
title={t("remove_from_history")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</div>
)}
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-4.5 h-4.5" />
) : (
<Eye className="w-4.5 h-4.5" />
)}
</button>
</div>
{/* 2FA Checkbox */}
<div>
<label className="flex items-center gap-2.5 cursor-pointer group select-none">
<span className="relative flex items-center justify-center">
<input
type="checkbox"
checked={showTotpField}
onChange={(e) => {
setShowTotpField(e.target.checked);
if (!e.target.checked) setTotpCode("");
}}
className="peer sr-only"
/>
<span className="flex items-center justify-center w-4.5 h-4.5 rounded border border-border bg-secondary/50 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background transition-colors">
{showTotpField && (
<svg className="w-3 h-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</span>
<span className="flex items-center gap-1.5 text-sm text-muted-foreground group-hover:text-foreground transition-colors">
<ShieldCheck className="w-4 h-4" />
{t("totp_checkbox")}
</span>
</label>
{!showTotpField && (
<p className="text-xs text-muted-foreground/80 mt-1.5 ml-7">
{t("totp_hint")}
</p>
)}
</div>
{/* TOTP Input with slide animation */}
<div
className="grid transition-all duration-200 ease-out"
style={{
gridTemplateRows: showTotpField ? '1fr' : '0fr',
opacity: showTotpField ? 1 : 0,
}}
>
<div className="overflow-hidden">
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
tabIndex={showTotpField ? 0 : -1}
aria-hidden={!showTotpField}
/>
</div>
</div>
</fieldset>
<Button
type="submit"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
t("sign_in")
)}
</Button>
</form>
</div>
</div>
);
}