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
This commit is contained in:
Matthieu MALVACHE
2026-02-17 02:31:50 +01:00
committed by Matthieu MALVACHE
parent 2636a88820
commit a43096485b
44 changed files with 2148 additions and 669 deletions
+147 -58
View File
@@ -7,7 +7,8 @@ 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 { Mail, AlertCircle, Loader2, X, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, ShieldCheck, Info, Eye, EyeOff } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
@@ -15,13 +16,15 @@ export default function LoginPage() {
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
// All hooks must be called unconditionally at the top
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);
@@ -30,15 +33,33 @@ export default function LoginPage() {
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);
// Set page title
useEffect(() => {
if (serverUrl) {
document.title = appName;
}
}, [appName, serverUrl]);
// Load saved usernames from localStorage on mount
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");
@@ -62,10 +83,8 @@ export default function LoginPage() {
clearError();
}, [formData, clearError]);
// Filter suggestions based on input
useEffect(() => {
if (!serverUrl) return;
// Skip showing suggestions if we just selected one
if (justSelectedSuggestion.current) {
justSelectedSuggestion.current = false;
return;
@@ -79,14 +98,13 @@ export default function LoginPage() {
setShowSuggestions(filtered.length > 0);
} else if (formData.username === "" && savedUsernames.length > 0) {
setFilteredSuggestions(savedUsernames);
setShowSuggestions(false); // Don't show on empty input
setShowSuggestions(false);
} else {
setShowSuggestions(false);
}
setSelectedSuggestionIndex(-1);
}, [formData.username, savedUsernames, serverUrl]);
// Close suggestions when clicking outside
useEffect(() => {
if (!serverUrl) return;
const handleClickOutside = (event: MouseEvent) => {
@@ -100,7 +118,12 @@ export default function LoginPage() {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [serverUrl]);
// Show loading state while config is being fetched
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">
@@ -112,7 +135,6 @@ export default function LoginPage() {
);
}
// Show error if config fetch failed
if (configError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -129,7 +151,6 @@ export default function LoginPage() {
);
}
// Show error if JMAP server URL is not configured
if (!serverUrl) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -146,7 +167,6 @@ export default function LoginPage() {
);
}
// Save username on successful login
const saveUsername = (username: string) => {
const saved = localStorage.getItem("webmail_usernames");
let usernames: string[] = [];
@@ -159,7 +179,6 @@ export default function LoginPage() {
}
}
// Add username if not already present, keep max 5 recent usernames
if (!usernames.includes(username)) {
usernames = [username, ...usernames].slice(0, 5);
localStorage.setItem("webmail_usernames", JSON.stringify(usernames));
@@ -167,7 +186,6 @@ export default function LoginPage() {
}
};
// Remove a username from saved list
const removeUsername = (username: string, e: React.MouseEvent) => {
e.stopPropagation();
const updated = savedUsernames.filter(u => u !== username);
@@ -195,7 +213,6 @@ export default function LoginPage() {
justSelectedSuggestion.current = true;
setFormData({ ...formData, username });
setShowSuggestions(false);
// Focus password field
document.getElementById("password")?.focus();
};
@@ -248,6 +265,28 @@ export default function LoginPage() {
</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">
@@ -261,8 +300,11 @@ export default function LoginPage() {
)}
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-4">
<form
onSubmit={handleSubmit}
className={cn("space-y-4", shakeError && "animate-shake")}
>
<fieldset disabled={isLoading} className="space-y-4">
<div className="relative">
<Input
ref={inputRef}
@@ -290,9 +332,10 @@ export default function LoginPage() {
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={`px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors ${
index === selectedSuggestionIndex ? "bg-muted" : ""
}`}
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>
@@ -310,45 +353,91 @@ export default function LoginPage() {
)}
</div>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
{/* TOTP Toggle */}
<button
type="button"
onClick={() => {
setShowTotpField(!showTotpField);
if (showTotpField) setTotpCode("");
}}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ShieldCheck className="w-4 h-4" />
{showTotpField ? t("totp_hide") : t("totp_toggle")}
</button>
{/* TOTP Input */}
{showTotpField && (
<div className="relative">
<Input
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"
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"
/>
)}
</div>
<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"
@@ -368,4 +457,4 @@ export default function LoginPage() {
</div>
</div>
);
}
}