feat: add Stalwart account security management

- Add Stalwart API client library (lib/stalwart/client.ts)
- Add server-side proxy routes for auth, crypto, password, principal, probe
- Add account security Zustand store with full state management
- Add Security settings tab with password change, display name, TOTP 2FA,
  app passwords, and encryption-at-rest controls
- Add stalwartFeaturesEnabled config flag (opt-out via STALWART_FEATURES=false)
- Add i18n translations for all 8 locales (en, de, es, fr, it, ja, nl, pt)
- Add tests for Stalwart client (24 tests) and security store (29 tests)
This commit is contained in:
Linus Rath
2026-03-12 02:20:56 +01:00
parent 68d4a9a641
commit ab72fc06ff
26 changed files with 3143 additions and 310 deletions
+366 -278
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "@/i18n/navigation";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -10,13 +10,19 @@ 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 { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } 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";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
{ value: "dark" as const, icon: Moon, label: "Dark" },
{ value: "system" as const, icon: Monitor, label: "System" },
];
export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
@@ -35,6 +41,7 @@ export default function LoginPage() {
const [sessionExpired, setSessionExpired] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [showThemeMenu, setShowThemeMenu] = useState(false);
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
@@ -49,6 +56,7 @@ export default function LoginPage() {
const justSelectedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
initializeTheme();
@@ -138,6 +146,9 @@ export default function LoginPage() {
inputRef.current && !inputRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
setShowThemeMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
@@ -157,9 +168,14 @@ export default function LoginPage() {
});
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
setTheme(newTheme);
setShowThemeMenu(false);
}, [setTheme]);
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="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<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>
@@ -170,15 +186,17 @@ export default function LoginPage() {
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 className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl p-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-500/10 mb-5">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("config_error.fetch_failed")}
</p>
</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>
);
@@ -186,15 +204,17 @@ export default function LoginPage() {
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 className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl p-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-500/10 mb-5">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("config_error.server_not_configured")}
</p>
</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>
);
@@ -334,287 +354,355 @@ export default function LoginPage() {
}
};
const themeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
const ThemeIcon = themeIcon;
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
const CurrentThemeIcon = currentThemeOption.icon;
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-background to-muted/20 relative">
{/* Theme toggle - top right */}
<div className="absolute top-4 right-4">
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle - top right, dropdown style */}
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
<button
type="button"
onClick={() => {
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
setTheme(next);
}}
className="p-2.5 rounded-lg bg-secondary/60 hover:bg-secondary border border-border/50 text-muted-foreground hover:text-foreground transition-colors"
aria-label={`Theme: ${theme}`}
title={`Theme: ${theme}`}
onClick={() => setShowThemeMenu(!showThemeMenu)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
showThemeMenu
? "bg-secondary border-border text-foreground shadow-md"
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
)}
aria-label={`Theme: ${currentThemeOption.label}`}
aria-expanded={showThemeMenu}
aria-haspopup="listbox"
>
<ThemeIcon className="w-4 h-4" />
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
</button>
{showThemeMenu && (
<div
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="listbox"
aria-label="Theme selection"
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon;
const isActive = theme === option.value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isActive}
onClick={() => handleThemeSelect(option.value)}
className={cn(
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
isActive
? "bg-primary/10 text-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
})}
</div>
)}
</div>
<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 && totpCode
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
<div className="w-full max-w-[400px] mx-auto">
{/* Card container */}
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
{/* Header section with logo */}
<div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary/80 mb-5 shadow-lg shadow-primary/25">
<Mail className="w-8 h-8 text-primary-foreground" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{appName}
</h1>
<p className="text-sm text-muted-foreground mt-1.5">
{t("title") !== appName ? t("title") : "Sign in to your account"}
</p>
</div>
)}
{/* Dev Mode: One-click login */}
{devMode ? (
<div className="space-y-4">
<Button
type="button"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
onClick={handleDevLogin}
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in")}
</div>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Dev mode logging in as dev@localhost
</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"
/>
{/* Form section */}
<div className="px-8 pb-8">
{/* Session Expired Banner */}
{sessionExpired && (
<div
className="mb-5 p-3.5 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-start gap-3"
role="status"
aria-live="polite"
>
<Info className="w-4.5 h-4.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 leading-relaxed">
{t("session_expired")}
</p>
<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}
onClick={() => setSessionExpired(false)}
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0"
aria-label={t("dismiss")}
>
{showPassword ? (
<EyeOff className="w-4.5 h-4.5" />
) : (
<Eye className="w-4.5 h-4.5" />
)}
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</button>
</div>
{!showTotpField ? (
<button
type="button"
onClick={() => {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 50);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
>
{t("totp_toggle")}
</button>
) : (
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => 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 && (
<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={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
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">
{rememberMe && (
<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="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
{t("remember_me")}
</span>
</label>
)}
</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>
{oauthMetadata && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">{t("or")}</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full h-12 font-medium text-base"
onClick={handleOAuthLogin}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
)}
{t("sign_in_sso")}
</Button>
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
{/* Error Message */}
{error && (
<div className={cn(
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
shakeError && "animate-shake"
)}>
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
{error === 'invalid_credentials' && showTotpField && totpCode
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
</form>
)}
</div>
{/* Version number - bottom center */}
<div className="absolute bottom-4 text-xs text-muted-foreground/50">
v{APP_VERSION}
{/* Dev Mode: One-click login */}
{devMode ? (
<div className="space-y-4">
<Button
type="button"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/20"
onClick={handleDevLogin}
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in")}
</div>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Dev mode logging in as dev@localhost
</p>
</div>
) : (
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4">
{/* Username field */}
<div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground">
{t("username_label")}
</label>
<div className="relative">
<Input
ref={inputRef}
id="username"
type="text"
value={formData.username}
onChange={handleUsernameChange}
onFocus={handleUsernameFocus}
onKeyDown={handleKeyDown}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
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.5 w-full bg-background border border-border rounded-xl shadow-lg z-50 overflow-hidden"
>
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={cn(
"px-3.5 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-secondary rounded-md transition-colors"
title={t("remove_from_history")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Password field */}
<div className="space-y-1.5">
<label htmlFor="password" className="block text-sm font-medium text-foreground">
{t("password_label")}
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
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-md text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* 2FA toggle / field */}
{!showTotpField ? (
<button
type="button"
onClick={() => {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 50);
}}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Shield className="w-3.5 h-3.5" />
{t("totp_toggle")}
</button>
) : (
<div className="space-y-1.5">
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
{t("totp_label")}
</label>
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
aria-label={t("totp_label")}
/>
</div>
)}
{/* Remember me */}
{rememberMeEnabled && (
<label className="flex items-center gap-2.5 cursor-pointer group select-none pt-1">
<span className="relative flex items-center justify-center">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="peer sr-only"
/>
<span className="flex items-center justify-center w-[18px] h-[18px] rounded-[5px] border border-border/80 bg-muted/40 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-all duration-200">
{rememberMe && (
<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="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
{t("remember_me")}
</span>
</label>
)}
</fieldset>
<Button
type="submit"
className="w-full h-11 font-medium text-[15px] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover: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>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in")}
</div>
)}
</Button>
{oauthMetadata && (
<>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/60" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background/80 px-3 text-muted-foreground">{t("or")}</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleOAuthLogin}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
)}
{t("sign_in_sso")}
</Button>
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
</p>
</div>
)}
</form>
)}
</div>
</div>
{/* Version number - below card */}
<p className="text-center text-xs text-muted-foreground/40 mt-6">
v{APP_VERSION}
</p>
</div>
</div>
);
+12 -6
View File
@@ -52,6 +52,9 @@ export default function Home() {
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
// Column resize state (disable transitions during drag)
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(0);
// Mobile conversation view state
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
@@ -834,7 +837,8 @@ export default function Home() {
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
<div
className={cn(
"flex-shrink-0 h-full z-50 transition-[width] duration-300",
"flex-shrink-0 h-full z-50",
!isResizing && "transition-[width] duration-300",
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
@@ -865,8 +869,9 @@ export default function Home() {
{/* Sidebar resize handle (desktop only, hidden when collapsed) */}
{!isMobile && !isTablet && !sidebarCollapsed && (
<ResizeHandle
onResize={(delta) => setSidebarWidth(sidebarWidth + delta)}
onResizeEnd={persistColumnWidths}
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)}
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
onDoubleClick={resetSidebarWidth}
/>
)}
@@ -883,7 +888,7 @@ export default function Home() {
isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation
"md:flex-shrink-0 md:shadow-sm",
"transition-all duration-200 ease-out",
!isResizing && "transition-all duration-200 ease-out",
// Tablet: collapse when email selected
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
@@ -1180,8 +1185,9 @@ export default function Home() {
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && (
<ResizeHandle
onResize={(delta) => setEmailListWidth(emailListWidth + delta)}
onResizeEnd={persistColumnWidths}
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
onDoubleClick={resetEmailListWidth}
/>
)}
+6 -1
View File
@@ -16,19 +16,22 @@ import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced';
export default function SettingsPage() {
const router = useRouter();
const t = useTranslations('settings');
const { client, isAuthenticated, logout } = useAuthStore();
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const [activeTab, setActiveTab] = useState<Tab>('appearance');
const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop();
@@ -52,6 +55,7 @@ export default function SettingsPage() {
{ id: 'appearance', label: t('tabs.appearance') },
{ id: 'email', label: t('tabs.email') },
{ id: 'account', label: t('tabs.account') },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security') }] : []),
{ id: 'identities', label: t('tabs.identities') },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []),
@@ -76,6 +80,7 @@ export default function SettingsPage() {
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <CalendarSettings />}
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
/**
* Extract the user's JMAP server URL and auth header from the session cookie
* or from the Authorization header passed by the client.
*/
async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string; username: string } | null> {
// Try Authorization header first (for bearer/basic auth forwarding)
const authHeader = request.headers.get('Authorization');
const serverUrl = request.headers.get('X-JMAP-Server-URL');
const username = request.headers.get('X-JMAP-Username');
if (authHeader && serverUrl && username) {
return { serverUrl, authHeader, username };
}
// Fall back to session cookie
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const credentials = decryptSession(token);
if (!credentials) return null;
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
return { serverUrl: credentials.serverUrl, authHeader: basic, username: credentials.username };
}
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.serverUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart auth info failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch auth info', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/auth
* Proxy to Stalwart POST /api/account/auth
*/
export async function POST(request: NextRequest) {
try {
const creds = await getCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.serverUrl}/api/account/auth`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart auth update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string; username: string } | null> {
const authHeader = request.headers.get('Authorization');
const serverUrl = request.headers.get('X-JMAP-Server-URL');
const username = request.headers.get('X-JMAP-Username');
if (authHeader && serverUrl && username) {
return { serverUrl, authHeader, username };
}
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const credentials = decryptSession(token);
if (!credentials) return null;
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
return { serverUrl: credentials.serverUrl, authHeader: basic, username: credentials.username };
}
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
*/
export async function GET(request: NextRequest) {
try {
const creds = await getCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.serverUrl}/api/account/crypto`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart crypto info failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch crypto info', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/crypto
* Proxy to Stalwart POST /api/account/crypto
*/
export async function POST(request: NextRequest) {
try {
const creds = await getCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.serverUrl}/api/account/crypto`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart crypto update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession, encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
};
async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string; username: string; hasSessionCookie: boolean } | null> {
const authHeader = request.headers.get('Authorization');
const serverUrl = request.headers.get('X-JMAP-Server-URL');
const username = request.headers.get('X-JMAP-Username');
if (authHeader && serverUrl && username) {
const cookieStore = await cookies();
const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value;
return { serverUrl, authHeader, username, hasSessionCookie };
}
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const credentials = decryptSession(token);
if (!credentials) return null;
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
return { serverUrl: credentials.serverUrl, authHeader: basic, username: credentials.username, hasSessionCookie: true };
}
/**
* POST /api/account/stalwart/password
* Change user password via Stalwart PATCH /api/principal/{name}
*
* Body: { currentPassword: string, newPassword: string }
*/
export async function POST(request: NextRequest) {
try {
const creds = await getCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
// Verify current password by attempting to authenticate
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': verifyAuth },
});
if (!verifyResponse.ok) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
}
// Change password via Stalwart principal API
const response = await fetch(`${creds.serverUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{ action: 'set', field: 'secrets', value: newPassword },
]),
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart password change failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to change password', details: text },
{ status: response.status }
);
}
// If session cookie exists, update it with the new password
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string; username: string } | null> {
const authHeader = request.headers.get('Authorization');
const serverUrl = request.headers.get('X-JMAP-Server-URL');
const username = request.headers.get('X-JMAP-Username');
if (authHeader && serverUrl && username) {
return { serverUrl, authHeader, username };
}
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const credentials = decryptSession(token);
if (!credentials) return null;
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
return { serverUrl: credentials.serverUrl, authHeader: basic, username: credentials.username };
}
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
*/
export async function GET(request: NextRequest) {
try {
const creds = await getCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.serverUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart principal fetch failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch principal', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/account/stalwart/principal
* Proxy to Stalwart PATCH /api/principal/{username}
* Body: PrincipalUpdateAction[] (array of {action, field, value})
*/
export async function PATCH(request: NextRequest) {
try {
const creds = await getCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// Prevent secrets field from being changed through this endpoint (use /password instead)
if (Array.isArray(body)) {
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
if (hasSecrets) {
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
}
}
const response = await fetch(`${creds.serverUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart principal update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string } | null> {
const authHeader = request.headers.get('Authorization');
const serverUrl = request.headers.get('X-JMAP-Server-URL');
if (authHeader && serverUrl) {
return { serverUrl, authHeader };
}
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const credentials = decryptSession(token);
if (!credentials) return null;
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
return { serverUrl: credentials.serverUrl, authHeader: basic };
}
/**
* GET /api/account/stalwart/probe
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwart: false });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${creds.serverUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json({ isStalwart: false });
}
const data = await response.json();
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
return NextResponse.json({ isStalwart });
} catch {
clearTimeout(timeout);
return NextResponse.json({ isStalwart: false });
}
} catch (error) {
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ isStalwart: false });
}
}
+1
View File
@@ -23,6 +23,7 @@ export async function GET() {
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
rememberMeEnabled: !!process.env.SESSION_SECRET,
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET,
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false',
devMode: process.env.DEV_MOCK_JMAP === 'true',
});
}