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
+1
View File
@@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
/specifications/
# debug
npm-debug.log*
+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',
});
}
+7 -6
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ResizeHandleProps {
onResizeStart?: () => void;
onResize: (delta: number) => void;
onResizeEnd?: () => void;
onDoubleClick?: () => void;
@@ -12,17 +13,18 @@ interface ResizeHandleProps {
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
const isDragging = useRef(false);
const lastX = useRef(0);
const startX = useRef(0);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
lastX.current = e.clientX;
startX.current = e.clientX;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, []);
onResizeStart?.();
}, [onResizeStart]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let delta = 0;
@@ -37,8 +39,7 @@ export function ResizeHandle({ onResize, onResizeEnd, onDoubleClick, className }
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const delta = e.clientX - lastX.current;
lastX.current = e.clientX;
const delta = e.clientX - startX.current;
onResize(delta);
};
@@ -0,0 +1,517 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
function PasswordChangeSection() {
const t = useTranslations('settings.security');
const { changePassword, isSaving } = useAccountSecurityStore();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (newPassword.length < 8) {
setError(t('password.error_min_length'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('password.error_mismatch'));
return;
}
try {
await changePassword(currentPassword, newPassword);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
toast.success(t('password.success'));
} catch (err) {
const msg = err instanceof Error ? err.message : t('password.error_generic');
setError(msg);
toast.error(t('password.error_title'), msg);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<Key className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('password.title')}</h4>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
<div className="relative">
<Input
type={showCurrent ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowCurrent(!showCurrent)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.new')}</label>
<div className="relative">
<Input
type={showNew ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNew ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.confirm')}</label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Button
type="submit"
size="sm"
disabled={isSaving || !currentPassword || !newPassword || !confirmPassword}
>
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{t('password.submit')}
</Button>
</form>
</div>
);
}
function DisplayNameSection() {
const t = useTranslations('settings.security');
const { displayName, updateDisplayName, isSaving, isLoadingPrincipal } = useAccountSecurityStore();
const [name, setName] = useState(displayName);
const [saved, setSaved] = useState(false);
useEffect(() => {
setName(displayName);
}, [displayName]);
const handleSave = async () => {
try {
await updateDisplayName(name);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
toast.success(t('display_name.success'));
} catch (err) {
toast.error(t('display_name.error'), err instanceof Error ? err.message : undefined);
}
};
if (isLoadingPrincipal) {
return (
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</SettingItem>
);
}
return (
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
<div className="flex items-center gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-48"
/>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving || name === displayName}
>
{saved ? <Check className="w-4 h-4" /> : isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : t('display_name.save')}
</Button>
</div>
</SettingItem>
);
}
function TotpSection() {
const t = useTranslations('settings.security');
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [totpUrl, setTotpUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleToggle = async (enable: boolean) => {
try {
if (enable) {
const url = await enableTotp();
setTotpUrl(url);
toast.success(t('totp.enabled'));
} else {
await disableTotp();
setTotpUrl(null);
toast.success(t('totp.disabled'));
}
} catch (err) {
toast.error(
enable ? t('totp.enable_error') : t('totp.disable_error'),
err instanceof Error ? err.message : undefined
);
}
};
const handleCopyUrl = () => {
if (totpUrl) {
navigator.clipboard.writeText(totpUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
};
if (isLoadingAuth) {
return (
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</SettingItem>
);
}
return (
<div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={otpEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{otpEnabled ? t('totp.active') : t('totp.inactive')}
</span>
</div>
</SettingItem>
{totpUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
{totpUrl}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
</div>
)}
</div>
);
}
function AppPasswordsSection() {
const t = useTranslations('settings.security');
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newPassword, setNewPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const generatePassword = useCallback(() => {
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let result = '';
const array = new Uint8Array(24);
crypto.getRandomValues(array);
for (const byte of array) {
result += chars[byte % chars.length];
}
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
return result.match(/.{1,4}/g)?.join('-') ?? result;
}, []);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
const password = newPassword || generatePassword();
try {
await addAppPassword(newName.trim(), password);
setNewName('');
setNewPassword('');
setShowAdd(false);
toast.success(t('app_passwords.added'));
} catch (err) {
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined);
}
};
const handleRemove = async (name: string) => {
try {
await removeAppPassword(name);
toast.success(t('app_passwords.removed'));
} catch (err) {
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
}
};
if (isLoadingAuth) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
</div>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 mr-1" />
{t('app_passwords.add')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
{showAdd && (
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('app_passwords.name_placeholder')}
required
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('app_passwords.password_placeholder')}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
{t('app_passwords.generate')}
</Button>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('app_passwords.create')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowAdd(false)}>
{t('app_passwords.cancel')}
</Button>
</div>
</form>
)}
{appPasswords.length > 0 ? (
<div className="space-y-1">
{appPasswords.map((name) => (
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
<span className="text-sm text-foreground">{name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(name)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p>
)}
</div>
);
}
function EncryptionSection() {
const t = useTranslations('settings.security');
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore();
const handleToggle = async (enabled: boolean) => {
try {
if (enabled) {
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
toast.success(t('encryption.enabled'));
} else {
await updateEncryption({ type: 'disabled' });
toast.success(t('encryption.disabled_success'));
}
} catch (err) {
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
}
};
if (isLoadingCrypto) {
return (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</SettingItem>
);
}
const isEnabled = encryptionType !== 'disabled';
return (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={isEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
</span>
</div>
</SettingItem>
);
}
export function AccountSecuritySettings() {
const t = useTranslations('settings.security');
const { isStalwart, isProbing, probe, fetchAll } = useAccountSecurityStore();
const { isAuthenticated } = useAuthStore();
useEffect(() => {
if (isAuthenticated && isStalwart === null) {
probe().then((detected) => {
if (detected) {
fetchAll();
}
});
}
}, [isAuthenticated, isStalwart, probe, fetchAll]);
if (isProbing) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="flex items-center gap-2 py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{t('detecting')}</span>
</div>
</SettingsSection>
);
}
if (isStalwart === false) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p>
</SettingsSection>
);
}
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-6">
{/* Password Change */}
<PasswordChangeSection />
<div className="border-t border-border" />
{/* Display Name */}
<DisplayNameSection />
<div className="border-t border-border" />
{/* Two-Factor Authentication */}
<div>
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
</div>
<TotpSection />
</div>
<div className="border-t border-border" />
{/* App Passwords */}
<AppPasswordsSection />
<div className="border-t border-border" />
{/* Encryption at Rest */}
<div>
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
</div>
<EncryptionSection />
</div>
</div>
</SettingsSection>
);
}
+4
View File
@@ -10,6 +10,7 @@ interface ConfigData {
oauthIssuerUrl: string;
rememberMeEnabled: boolean;
settingsSyncEnabled: boolean;
stalwartFeaturesEnabled: boolean;
devMode: boolean;
}
@@ -68,6 +69,7 @@ export function useConfig(): AppConfig {
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
rememberMeEnabled: configCache?.rememberMeEnabled || false,
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
devMode: configCache?.devMode || false,
isLoading: !configCache,
error: null,
@@ -84,6 +86,7 @@ export function useConfig(): AppConfig {
oauthIssuerUrl: configCache.oauthIssuerUrl,
rememberMeEnabled: configCache.rememberMeEnabled,
settingsSyncEnabled: configCache.settingsSyncEnabled,
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
devMode: configCache.devMode,
isLoading: false,
error: null,
@@ -101,6 +104,7 @@ export function useConfig(): AppConfig {
oauthIssuerUrl: data.oauthIssuerUrl,
rememberMeEnabled: data.rememberMeEnabled,
settingsSyncEnabled: data.settingsSyncEnabled,
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
devMode: data.devMode,
isLoading: false,
error: null,
+246
View File
@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StalwartClient } from '../stalwart/client';
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('StalwartClient', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let client: StalwartClient;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
client = new StalwartClient('https://mail.example.com/', 'Basic dXNlcjpwYXNz');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('constructor', () => {
it('strips trailing slash from server URL', () => {
const c = new StalwartClient('https://mail.example.com/', 'Basic abc');
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }));
c.getAuthInfo();
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/account/auth',
expect.anything()
);
});
});
describe('probe', () => {
it('returns true when server responds with data field', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false } }));
const result = await client.probe();
expect(result).toBe(true);
});
it('returns true when server returns 401 (API exists but needs auth)', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(401));
const result = await client.probe();
expect(result).toBe(true);
});
it('returns false when server returns 404', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(404));
const result = await client.probe();
expect(result).toBe(false);
});
it('returns false on network error', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
const result = await client.probe();
expect(result).toBe(false);
});
it('returns false when response has no data field', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { something: 'else' }));
const result = await client.probe();
expect(result).toBe(false);
});
});
describe('getAuthInfo', () => {
it('returns auth info on success', async () => {
const authInfo = { otpEnabled: true, isAdminApp: false, appPasswords: ['app1'] };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: authInfo }));
const result = await client.getAuthInfo();
expect(result).toEqual(authInfo);
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/account/auth',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Basic dXNlcjpwYXNz',
}),
})
);
});
it('throws on non-ok response', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { detail: 'Forbidden' }));
await expect(client.getAuthInfo()).rejects.toThrow('Forbidden');
});
it('throws with HTTP status when error body is unparseable', async () => {
fetchSpy.mockResolvedValueOnce(new Response('not json', { status: 500 }));
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 500');
});
});
describe('enableTotp', () => {
it('sends enableOtpAuth action and returns TOTP URL', async () => {
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC123';
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
const result = await client.enableTotp();
expect(result).toBe(totpUrl);
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'enableOtpAuth' }]);
});
});
describe('disableTotp', () => {
it('sends disableOtpAuth action', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.disableTotp();
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'disableOtpAuth' }]);
});
});
describe('addAppPassword', () => {
it('sends addAppPassword action with name and password', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.addAppPassword('Thunderbird', 'secret123');
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret123' }]);
});
});
describe('removeAppPassword', () => {
it('sends removeAppPassword action with name', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.removeAppPassword('Thunderbird');
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
});
});
describe('getCryptoInfo', () => {
it('returns crypto info on success', async () => {
const cryptoInfo = { type: 'pgp' as const };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: cryptoInfo }));
const result = await client.getCryptoInfo();
expect(result).toEqual(cryptoInfo);
});
});
describe('updateCrypto', () => {
it('sends crypto settings', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updateCrypto({ type: 'pgp' });
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(callBody).toEqual({ type: 'pgp' });
});
});
describe('getPrincipal', () => {
it('returns principal data on success', async () => {
const principal = {
id: 1, type: 'individual', name: 'testuser',
description: 'Test User', emails: ['test@example.com'],
secrets: [], quota: 1000000, roles: ['user'], lists: [],
};
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: principal }));
const result = await client.getPrincipal('testuser');
expect(result).toEqual(principal);
});
it('encodes special characters in username', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await client.getPrincipal('user@example.com');
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.example.com/api/principal/user%40example.com',
expect.anything()
);
});
});
describe('updatePrincipal', () => {
it('sends PATCH with action array', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updatePrincipal('testuser', [
{ action: 'set', field: 'description', value: 'New Name' },
]);
const call = fetchSpy.mock.calls[0];
expect(call[0]).toBe('https://mail.example.com/api/principal/testuser');
expect(call[1]?.method).toBe('PATCH');
const body = JSON.parse(call[1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
});
});
describe('changePassword', () => {
it('sends set secrets action via updatePrincipal', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.changePassword('testuser', 'newPassword123');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'secrets', value: 'newPassword123' }]);
});
});
describe('updateDisplayName', () => {
it('sends set description action via updatePrincipal', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await client.updateDisplayName('testuser', 'John Doe');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'John Doe' }]);
});
});
describe('request error handling', () => {
it('parses error.detail from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { detail: 'Invalid request format' }));
await expect(client.getAuthInfo()).rejects.toThrow('Invalid request format');
});
it('parses error.details from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { details: 'Bad stuff' }));
await expect(client.getAuthInfo()).rejects.toThrow('Bad stuff');
});
it('parses error.error from response body', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'Something wrong' }));
await expect(client.getAuthInfo()).rejects.toThrow('Something wrong');
});
it('falls back to HTTP status code on non-JSON error', async () => {
fetchSpy.mockResolvedValueOnce(new Response('plain text', { status: 502 }));
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 502');
});
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* Stalwart Management API Client
*
* Provides typed access to Stalwart's /api/ endpoints for user self-service:
* - Password change (PATCH /principal/{name})
* - Display name update (PATCH /principal/{name})
* - App passwords (POST /account/auth)
* - TOTP 2FA management (POST /account/auth)
* - Encryption-at-rest (GET/POST /account/crypto)
* - Account auth info (GET /account/auth)
*/
export interface StalwartAuthInfo {
otpEnabled: boolean;
isAdminApp: boolean;
appPasswords: string[];
}
export interface StalwartCryptoInfo {
type: 'disabled' | 'pgp' | 'smime';
}
export interface StalwartPrincipal {
id: number;
type: string;
name: string;
description: string;
emails: string | string[];
secrets: string | string[];
quota: number;
roles: string[];
lists: string[];
}
export interface PrincipalUpdateAction {
action: 'set' | 'addItem' | 'removeItem';
field: string;
value: string | number;
}
export interface StalwartApiError {
error: string;
details: string;
reason?: string | null;
}
export class StalwartClient {
private baseUrl: string;
private authHeader: string;
constructor(serverUrl: string, authHeader: string) {
this.baseUrl = serverUrl.replace(/\/$/, '') + '/api';
this.authHeader = authHeader;
}
// eslint-disable-next-line no-undef
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!response.ok) {
let errorDetail = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body.detail) errorDetail = body.detail;
else if (body.details) errorDetail = body.details;
else if (body.error) errorDetail = body.error;
} catch { /* use status code */ }
throw new Error(errorDetail);
}
return response.json();
}
/** Probe whether this server exposes Stalwart's management API */
async probe(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/account/auth`, {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
if (response.status === 401) return true; // API exists but needs auth
if (!response.ok) return false;
const data = await response.json();
return data.data !== undefined;
} catch {
return false;
}
}
/** GET /account/auth - Fetch 2FA and app password status */
async getAuthInfo(): Promise<StalwartAuthInfo> {
const result = await this.request<{ data: StalwartAuthInfo }>('/account/auth');
return result.data;
}
/** POST /account/auth - Update auth settings (TOTP, app passwords) */
async updateAuth(actions: Array<{ type: string; name?: string; password?: string; url?: string }>): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify(actions),
});
}
/** Enable TOTP - returns the TOTP URL for QR code generation */
async enableTotp(): Promise<string> {
const result = await this.request<{ data: string }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
});
return result.data;
}
/** Disable TOTP */
async disableTotp(): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
});
}
/** Add an app password */
async addAppPassword(name: string, password: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
});
}
/** Remove an app password */
async removeAppPassword(name: string): Promise<void> {
await this.request<{ data: unknown }>('/account/auth', {
method: 'POST',
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
});
}
/** GET /account/crypto - Fetch encryption-at-rest settings */
async getCryptoInfo(): Promise<StalwartCryptoInfo> {
const result = await this.request<{ data: StalwartCryptoInfo }>('/account/crypto');
return result.data;
}
/** POST /account/crypto - Update encryption-at-rest settings */
async updateCrypto(settings: { type: string; algo?: string; certs?: string }): Promise<void> {
await this.request<{ data: unknown }>('/account/crypto', {
method: 'POST',
body: JSON.stringify(settings),
});
}
/** GET /principal/{name} - Fetch principal details */
async getPrincipal(name: string): Promise<StalwartPrincipal> {
const result = await this.request<{ data: StalwartPrincipal }>(`/principal/${encodeURIComponent(name)}`);
return result.data;
}
/** PATCH /principal/{name} - Update principal fields */
async updatePrincipal(name: string, actions: PrincipalUpdateAction[]): Promise<void> {
await this.request<{ data: unknown }>(`/principal/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify(actions),
});
}
/** Change password via PATCH /principal/{name} */
async changePassword(name: string, newPassword: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'secrets', value: newPassword },
]);
}
/** Update display name via PATCH /principal/{name} */
async updateDisplayName(name: string, displayName: string): Promise<void> {
await this.updatePrincipal(name, [
{ action: 'set', field: 'description', value: displayName },
]);
}
}
+66 -1
View File
@@ -449,7 +449,8 @@
"filters": "Filter",
"templates": "Vorlagen",
"folders": "Ordner",
"keywords": "Schlüsselwörter"
"keywords": "Schlüsselwörter",
"security": "Sicherheit"
},
"tab_groups": {
"general": "Allgemein",
@@ -663,6 +664,70 @@
"value": "{time}"
}
},
"security": {
"title": "Kontosicherheit",
"description": "Verwalten Sie Ihr Passwort, Zwei-Faktor-Authentifizierung und Sicherheitseinstellungen",
"detecting": "Serverfunktionen werden erkannt...",
"not_available": "Kontosicherheitsverwaltung ist für diesen Mailserver nicht verfügbar.",
"password": {
"title": "Passwort ändern",
"current": "Aktuelles Passwort",
"new": "Neues Passwort",
"confirm": "Neues Passwort bestätigen",
"submit": "Passwort ändern",
"success": "Passwort erfolgreich geändert",
"error_title": "Passwortänderung fehlgeschlagen",
"error_mismatch": "Neue Passwörter stimmen nicht überein",
"error_min_length": "Das Passwort muss mindestens 8 Zeichen lang sein",
"error_generic": "Passwort konnte nicht geändert werden"
},
"display_name": {
"label": "Anzeigename",
"description": "Ihr Name, wie er auf dem Server angezeigt wird",
"save": "Speichern",
"success": "Anzeigename aktualisiert",
"error": "Anzeigename konnte nicht aktualisiert werden"
},
"totp": {
"section_title": "Zwei-Faktor-Authentifizierung",
"label": "TOTP-Authentifizierung",
"description": "Fügen Sie eine zusätzliche Sicherheitsebene mit einem zeitbasierten Einmalpasswort hinzu",
"active": "Aktiviert",
"inactive": "Deaktiviert",
"enabled": "Zwei-Faktor-Authentifizierung aktiviert",
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert",
"enable_error": "2FA konnte nicht aktiviert werden",
"disable_error": "2FA konnte nicht deaktiviert werden",
"setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):"
},
"app_passwords": {
"title": "App-Passwörter",
"description": "Erstellen Sie Passwörter für Apps, die keine Zwei-Faktor-Authentifizierung unterstützen",
"add": "Hinzufügen",
"create": "Erstellen",
"cancel": "Abbrechen",
"generate": "Generieren",
"name_label": "App-Name",
"name_placeholder": "z.B. Thunderbird, iPhone Mail",
"password_label": "Passwort (leer lassen für Auto-Generierung)",
"password_placeholder": "Automatisch generiert, wenn leer",
"added": "App-Passwort erstellt",
"removed": "App-Passwort entfernt",
"add_error": "App-Passwort konnte nicht erstellt werden",
"remove_error": "App-Passwort konnte nicht entfernt werden",
"none": "Keine App-Passwörter konfiguriert"
},
"encryption": {
"section_title": "Verschlüsselung im Ruhezustand",
"label": "E-Mail-Verschlüsselung",
"description": "Verschlüsseln Sie gespeicherte E-Mails auf dem Server für zusätzlichen Datenschutz",
"active": "{type}-Verschlüsselung aktiviert",
"inactive": "Deaktiviert",
"enabled": "Verschlüsselung im Ruhezustand aktiviert",
"disabled_success": "Verschlüsselung im Ruhezustand deaktiviert",
"error": "Verschlüsselungseinstellungen konnten nicht aktualisiert werden"
}
},
"identities": {
"title": "Sendeidentitäten",
"description": "Verwalten Sie E-Mail-Adressen, von denen Sie senden können",
+66 -1
View File
@@ -464,7 +464,8 @@
"filters": "Filters",
"templates": "Templates",
"folders": "Folders",
"keywords": "Keywords"
"keywords": "Keywords",
"security": "Security"
},
"tab_groups": {
"general": "General",
@@ -678,6 +679,70 @@
"value": "{time}"
}
},
"security": {
"title": "Account Security",
"description": "Manage your password, two-factor authentication, and security settings",
"detecting": "Detecting server capabilities...",
"not_available": "Account security management is not available for this mail server.",
"password": {
"title": "Change Password",
"current": "Current Password",
"new": "New Password",
"confirm": "Confirm New Password",
"submit": "Change Password",
"success": "Password changed successfully",
"error_title": "Password change failed",
"error_mismatch": "New passwords do not match",
"error_min_length": "Password must be at least 8 characters",
"error_generic": "Failed to change password"
},
"display_name": {
"label": "Display Name",
"description": "Your name as it appears on the server",
"save": "Save",
"success": "Display name updated",
"error": "Failed to update display name"
},
"totp": {
"section_title": "Two-Factor Authentication",
"label": "TOTP Authentication",
"description": "Add an extra layer of security with a time-based one-time password",
"active": "Enabled",
"inactive": "Disabled",
"enabled": "Two-factor authentication enabled",
"disabled": "Two-factor authentication disabled",
"enable_error": "Failed to enable 2FA",
"disable_error": "Failed to disable 2FA",
"setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):"
},
"app_passwords": {
"title": "App Passwords",
"description": "Create passwords for apps that don't support two-factor authentication",
"add": "Add",
"create": "Create",
"cancel": "Cancel",
"generate": "Generate",
"name_label": "App Name",
"name_placeholder": "e.g. Thunderbird, iPhone Mail",
"password_label": "Password (leave empty to auto-generate)",
"password_placeholder": "Auto-generated if empty",
"added": "App password created",
"removed": "App password removed",
"add_error": "Failed to create app password",
"remove_error": "Failed to remove app password",
"none": "No app passwords configured"
},
"encryption": {
"section_title": "Encryption at Rest",
"label": "Email Encryption",
"description": "Encrypt stored emails on the server for additional privacy",
"active": "{type} encryption enabled",
"inactive": "Disabled",
"enabled": "Encryption at rest enabled",
"disabled_success": "Encryption at rest disabled",
"error": "Failed to update encryption settings"
}
},
"identities": {
"title": "Sending Identities",
"description": "Manage email addresses you can send from",
+66 -1
View File
@@ -449,7 +449,8 @@
"filters": "Filtros",
"templates": "Plantillas",
"folders": "Carpetas",
"keywords": "Palabras clave"
"keywords": "Palabras clave",
"security": "Seguridad"
},
"tab_groups": {
"general": "General",
@@ -663,6 +664,70 @@
"value": "{time}"
}
},
"security": {
"title": "Seguridad de la cuenta",
"description": "Administre su contraseña, autenticación de dos factores y configuración de seguridad",
"detecting": "Detectando capacidades del servidor...",
"not_available": "La gestión de seguridad de la cuenta no está disponible para este servidor de correo.",
"password": {
"title": "Cambiar contraseña",
"current": "Contraseña actual",
"new": "Nueva contraseña",
"confirm": "Confirmar nueva contraseña",
"submit": "Cambiar contraseña",
"success": "Contraseña cambiada exitosamente",
"error_title": "Error al cambiar la contraseña",
"error_mismatch": "Las nuevas contraseñas no coinciden",
"error_min_length": "La contraseña debe tener al menos 8 caracteres",
"error_generic": "No se pudo cambiar la contraseña"
},
"display_name": {
"label": "Nombre para mostrar",
"description": "Su nombre tal como aparece en el servidor",
"save": "Guardar",
"success": "Nombre para mostrar actualizado",
"error": "No se pudo actualizar el nombre para mostrar"
},
"totp": {
"section_title": "Autenticación de dos factores",
"label": "Autenticación TOTP",
"description": "Añada una capa adicional de seguridad con una contraseña de un solo uso basada en tiempo",
"active": "Habilitado",
"inactive": "Deshabilitado",
"enabled": "Autenticación de dos factores habilitada",
"disabled": "Autenticación de dos factores deshabilitada",
"enable_error": "No se pudo habilitar 2FA",
"disable_error": "No se pudo deshabilitar 2FA",
"setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):"
},
"app_passwords": {
"title": "Contraseñas de aplicación",
"description": "Cree contraseñas para aplicaciones que no admiten autenticación de dos factores",
"add": "Agregar",
"create": "Crear",
"cancel": "Cancelar",
"generate": "Generar",
"name_label": "Nombre de la aplicación",
"name_placeholder": "p. ej. Thunderbird, iPhone Mail",
"password_label": "Contraseña (dejar vacío para auto-generar)",
"password_placeholder": "Auto-generada si está vacío",
"added": "Contraseña de aplicación creada",
"removed": "Contraseña de aplicación eliminada",
"add_error": "No se pudo crear la contraseña de aplicación",
"remove_error": "No se pudo eliminar la contraseña de aplicación",
"none": "No hay contraseñas de aplicación configuradas"
},
"encryption": {
"section_title": "Cifrado en reposo",
"label": "Cifrado de correo electrónico",
"description": "Cifre los correos almacenados en el servidor para mayor privacidad",
"active": "Cifrado {type} habilitado",
"inactive": "Deshabilitado",
"enabled": "Cifrado en reposo habilitado",
"disabled_success": "Cifrado en reposo deshabilitado",
"error": "No se pudieron actualizar las configuraciones de cifrado"
}
},
"identities": {
"title": "Identidades de Envío",
"description": "Administre las direcciones de correo desde las que puede enviar",
+66 -1
View File
@@ -449,7 +449,8 @@
"filters": "Filtres",
"templates": "Modèles",
"folders": "Dossiers",
"keywords": "Mots-clés"
"keywords": "Mots-clés",
"security": "Sécurité"
},
"tab_groups": {
"general": "Général",
@@ -663,6 +664,70 @@
"value": "{time}"
}
},
"security": {
"title": "Sécurité du compte",
"description": "Gérez votre mot de passe, l'authentification à deux facteurs et les paramètres de sécurité",
"detecting": "Détection des capacités du serveur...",
"not_available": "La gestion de la sécurité du compte n'est pas disponible pour ce serveur de messagerie.",
"password": {
"title": "Changer le mot de passe",
"current": "Mot de passe actuel",
"new": "Nouveau mot de passe",
"confirm": "Confirmer le nouveau mot de passe",
"submit": "Changer le mot de passe",
"success": "Mot de passe changé avec succès",
"error_title": "Échec du changement de mot de passe",
"error_mismatch": "Les nouveaux mots de passe ne correspondent pas",
"error_min_length": "Le mot de passe doit comporter au moins 8 caractères",
"error_generic": "Impossible de changer le mot de passe"
},
"display_name": {
"label": "Nom d'affichage",
"description": "Votre nom tel qu'il apparaît sur le serveur",
"save": "Enregistrer",
"success": "Nom d'affichage mis à jour",
"error": "Impossible de mettre à jour le nom d'affichage"
},
"totp": {
"section_title": "Authentification à deux facteurs",
"label": "Authentification TOTP",
"description": "Ajoutez une couche de sécurité supplémentaire avec un mot de passe à usage unique basé sur le temps",
"active": "Activé",
"inactive": "Désactivé",
"enabled": "Authentification à deux facteurs activée",
"disabled": "Authentification à deux facteurs désactivée",
"enable_error": "Impossible d'activer la 2FA",
"disable_error": "Impossible de désactiver la 2FA",
"setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :"
},
"app_passwords": {
"title": "Mots de passe d'application",
"description": "Créez des mots de passe pour les applications qui ne prennent pas en charge l'authentification à deux facteurs",
"add": "Ajouter",
"create": "Créer",
"cancel": "Annuler",
"generate": "Générer",
"name_label": "Nom de l'application",
"name_placeholder": "ex. Thunderbird, iPhone Mail",
"password_label": "Mot de passe (laisser vide pour auto-générer)",
"password_placeholder": "Auto-généré si vide",
"added": "Mot de passe d'application créé",
"removed": "Mot de passe d'application supprimé",
"add_error": "Impossible de créer le mot de passe d'application",
"remove_error": "Impossible de supprimer le mot de passe d'application",
"none": "Aucun mot de passe d'application configuré"
},
"encryption": {
"section_title": "Chiffrement au repos",
"label": "Chiffrement des e-mails",
"description": "Chiffrez les e-mails stockés sur le serveur pour une confidentialité accrue",
"active": "Chiffrement {type} activé",
"inactive": "Désactivé",
"enabled": "Chiffrement au repos activé",
"disabled_success": "Chiffrement au repos désactivé",
"error": "Impossible de mettre à jour les paramètres de chiffrement"
}
},
"identities": {
"title": "Identités d'envoi",
"description": "Gérer les adresses email depuis lesquelles vous pouvez envoyer",
+66 -1
View File
@@ -449,7 +449,8 @@
"filters": "Filtri",
"templates": "Modelli",
"folders": "Cartelle",
"keywords": "Parole chiave"
"keywords": "Parole chiave",
"security": "Sicurezza"
},
"tab_groups": {
"general": "Generale",
@@ -663,6 +664,70 @@
"value": "{time}"
}
},
"security": {
"title": "Sicurezza dell'account",
"description": "Gestisci la tua password, l'autenticazione a due fattori e le impostazioni di sicurezza",
"detecting": "Rilevamento delle funzionalità del server...",
"not_available": "La gestione della sicurezza dell'account non è disponibile per questo server di posta.",
"password": {
"title": "Cambia password",
"current": "Password attuale",
"new": "Nuova password",
"confirm": "Conferma nuova password",
"submit": "Cambia password",
"success": "Password cambiata con successo",
"error_title": "Cambio password fallito",
"error_mismatch": "Le nuove password non corrispondono",
"error_min_length": "La password deve contenere almeno 8 caratteri",
"error_generic": "Impossibile cambiare la password"
},
"display_name": {
"label": "Nome visualizzato",
"description": "Il tuo nome come appare sul server",
"save": "Salva",
"success": "Nome visualizzato aggiornato",
"error": "Impossibile aggiornare il nome visualizzato"
},
"totp": {
"section_title": "Autenticazione a due fattori",
"label": "Autenticazione TOTP",
"description": "Aggiungi un ulteriore livello di sicurezza con una password monouso basata sul tempo",
"active": "Abilitato",
"inactive": "Disabilitato",
"enabled": "Autenticazione a due fattori abilitata",
"disabled": "Autenticazione a due fattori disabilitata",
"enable_error": "Impossibile abilitare la 2FA",
"disable_error": "Impossibile disabilitare la 2FA",
"setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):"
},
"app_passwords": {
"title": "Password per le app",
"description": "Crea password per le app che non supportano l'autenticazione a due fattori",
"add": "Aggiungi",
"create": "Crea",
"cancel": "Annulla",
"generate": "Genera",
"name_label": "Nome dell'app",
"name_placeholder": "es. Thunderbird, iPhone Mail",
"password_label": "Password (lascia vuoto per auto-generare)",
"password_placeholder": "Auto-generata se vuoto",
"added": "Password per l'app creata",
"removed": "Password per l'app rimossa",
"add_error": "Impossibile creare la password per l'app",
"remove_error": "Impossibile rimuovere la password per l'app",
"none": "Nessuna password per le app configurata"
},
"encryption": {
"section_title": "Crittografia a riposo",
"label": "Crittografia email",
"description": "Crittografa le email archiviate sul server per una maggiore privacy",
"active": "Crittografia {type} abilitata",
"inactive": "Disabilitato",
"enabled": "Crittografia a riposo abilitata",
"disabled_success": "Crittografia a riposo disabilitata",
"error": "Impossibile aggiornare le impostazioni di crittografia"
}
},
"identities": {
"title": "Identità di invio",
"description": "Gestisci gli indirizzi email da cui puoi inviare",
+66 -1
View File
@@ -449,7 +449,8 @@
"filters": "フィルター",
"templates": "テンプレート",
"folders": "フォルダー",
"keywords": "キーワード"
"keywords": "キーワード",
"security": "セキュリティ"
},
"tab_groups": {
"general": "一般",
@@ -663,6 +664,70 @@
"value": "{time}"
}
},
"security": {
"title": "アカウントセキュリティ",
"description": "パスワード、二要素認証、セキュリティ設定を管理します",
"detecting": "サーバー機能を検出中...",
"not_available": "このメールサーバーではアカウントセキュリティ管理は利用できません。",
"password": {
"title": "パスワードの変更",
"current": "現在のパスワード",
"new": "新しいパスワード",
"confirm": "新しいパスワードの確認",
"submit": "パスワードを変更",
"success": "パスワードが正常に変更されました",
"error_title": "パスワードの変更に失敗しました",
"error_mismatch": "新しいパスワードが一致しません",
"error_min_length": "パスワードは8文字以上である必要があります",
"error_generic": "パスワードを変更できませんでした"
},
"display_name": {
"label": "表示名",
"description": "サーバー上に表示される名前",
"save": "保存",
"success": "表示名が更新されました",
"error": "表示名を更新できませんでした"
},
"totp": {
"section_title": "二要素認証",
"label": "TOTP認証",
"description": "時間ベースのワンタイムパスワードで追加のセキュリティレイヤーを追加",
"active": "有効",
"inactive": "無効",
"enabled": "二要素認証が有効になりました",
"disabled": "二要素認証が無効になりました",
"enable_error": "2FAを有効にできませんでした",
"disable_error": "2FAを無効にできませんでした",
"setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:"
},
"app_passwords": {
"title": "アプリパスワード",
"description": "二要素認証に対応していないアプリ用のパスワードを作成",
"add": "追加",
"create": "作成",
"cancel": "キャンセル",
"generate": "生成",
"name_label": "アプリ名",
"name_placeholder": "例:Thunderbird、iPhone Mail",
"password_label": "パスワード(空欄で自動生成)",
"password_placeholder": "空欄の場合自動生成",
"added": "アプリパスワードが作成されました",
"removed": "アプリパスワードが削除されました",
"add_error": "アプリパスワードを作成できませんでした",
"remove_error": "アプリパスワードを削除できませんでした",
"none": "アプリパスワードは設定されていません"
},
"encryption": {
"section_title": "保存時の暗号化",
"label": "メール暗号化",
"description": "サーバー上の保存メールを暗号化してプライバシーを強化",
"active": "{type}暗号化が有効",
"inactive": "無効",
"enabled": "保存時の暗号化が有効になりました",
"disabled_success": "保存時の暗号化が無効になりました",
"error": "暗号化設定を更新できませんでした"
}
},
"identities": {
"title": "送信者情報",
"description": "送信に使用するメールアドレスを管理",
+66 -1
View File
@@ -449,7 +449,8 @@
"filters": "Filters",
"templates": "Sjablonen",
"folders": "Mappen",
"keywords": "Sleutelwoorden"
"keywords": "Sleutelwoorden",
"security": "Beveiliging"
},
"tab_groups": {
"general": "Algemeen",
@@ -663,6 +664,70 @@
"value": "{time}"
}
},
"security": {
"title": "Accountbeveiliging",
"description": "Beheer uw wachtwoord, tweefactorauthenticatie en beveiligingsinstellingen",
"detecting": "Servermogelijkheden worden gedetecteerd...",
"not_available": "Accountbeveiligingsbeheer is niet beschikbaar voor deze mailserver.",
"password": {
"title": "Wachtwoord wijzigen",
"current": "Huidig wachtwoord",
"new": "Nieuw wachtwoord",
"confirm": "Bevestig nieuw wachtwoord",
"submit": "Wachtwoord wijzigen",
"success": "Wachtwoord succesvol gewijzigd",
"error_title": "Wachtwoord wijzigen mislukt",
"error_mismatch": "Nieuwe wachtwoorden komen niet overeen",
"error_min_length": "Wachtwoord moet minimaal 8 tekens bevatten",
"error_generic": "Kan wachtwoord niet wijzigen"
},
"display_name": {
"label": "Weergavenaam",
"description": "Uw naam zoals weergegeven op de server",
"save": "Opslaan",
"success": "Weergavenaam bijgewerkt",
"error": "Kan weergavenaam niet bijwerken"
},
"totp": {
"section_title": "Tweefactorauthenticatie",
"label": "TOTP-authenticatie",
"description": "Voeg een extra beveiligingslaag toe met een op tijd gebaseerd eenmalig wachtwoord",
"active": "Ingeschakeld",
"inactive": "Uitgeschakeld",
"enabled": "Tweefactorauthenticatie ingeschakeld",
"disabled": "Tweefactorauthenticatie uitgeschakeld",
"enable_error": "Kan 2FA niet inschakelen",
"disable_error": "Kan 2FA niet uitschakelen",
"setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):"
},
"app_passwords": {
"title": "App-wachtwoorden",
"description": "Maak wachtwoorden aan voor apps die geen tweefactorauthenticatie ondersteunen",
"add": "Toevoegen",
"create": "Aanmaken",
"cancel": "Annuleren",
"generate": "Genereren",
"name_label": "App-naam",
"name_placeholder": "bijv. Thunderbird, iPhone Mail",
"password_label": "Wachtwoord (leeg laten voor automatisch genereren)",
"password_placeholder": "Automatisch gegenereerd als leeg",
"added": "App-wachtwoord aangemaakt",
"removed": "App-wachtwoord verwijderd",
"add_error": "Kan app-wachtwoord niet aanmaken",
"remove_error": "Kan app-wachtwoord niet verwijderen",
"none": "Geen app-wachtwoorden geconfigureerd"
},
"encryption": {
"section_title": "Versleuteling in rust",
"label": "E-mailversleuteling",
"description": "Versleutel opgeslagen e-mails op de server voor extra privacy",
"active": "{type}-versleuteling ingeschakeld",
"inactive": "Uitgeschakeld",
"enabled": "Versleuteling in rust ingeschakeld",
"disabled_success": "Versleuteling in rust uitgeschakeld",
"error": "Kan versleutelingsinstellingen niet bijwerken"
}
},
"identities": {
"title": "Verzendidentiteiten",
"description": "Beheer e-mailadressen van waaruit je kunt verzenden",
+66 -1
View File
@@ -449,7 +449,8 @@
"filters": "Filtros",
"templates": "Modelos",
"folders": "Pastas",
"keywords": "Palavras-chave"
"keywords": "Palavras-chave",
"security": "Segurança"
},
"tab_groups": {
"general": "Geral",
@@ -663,6 +664,70 @@
"value": "{time}"
}
},
"security": {
"title": "Segurança da conta",
"description": "Gerencie sua senha, autenticação de dois fatores e configurações de segurança",
"detecting": "Detectando recursos do servidor...",
"not_available": "O gerenciamento de segurança da conta não está disponível para este servidor de e-mail.",
"password": {
"title": "Alterar senha",
"current": "Senha atual",
"new": "Nova senha",
"confirm": "Confirmar nova senha",
"submit": "Alterar senha",
"success": "Senha alterada com sucesso",
"error_title": "Falha ao alterar a senha",
"error_mismatch": "As novas senhas não coincidem",
"error_min_length": "A senha deve ter pelo menos 8 caracteres",
"error_generic": "Não foi possível alterar a senha"
},
"display_name": {
"label": "Nome de exibição",
"description": "Seu nome como aparece no servidor",
"save": "Salvar",
"success": "Nome de exibição atualizado",
"error": "Não foi possível atualizar o nome de exibição"
},
"totp": {
"section_title": "Autenticação de dois fatores",
"label": "Autenticação TOTP",
"description": "Adicione uma camada extra de segurança com uma senha única baseada em tempo",
"active": "Habilitado",
"inactive": "Desabilitado",
"enabled": "Autenticação de dois fatores habilitada",
"disabled": "Autenticação de dois fatores desabilitada",
"enable_error": "Não foi possível habilitar a 2FA",
"disable_error": "Não foi possível desabilitar a 2FA",
"setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):"
},
"app_passwords": {
"title": "Senhas de aplicativo",
"description": "Crie senhas para aplicativos que não suportam autenticação de dois fatores",
"add": "Adicionar",
"create": "Criar",
"cancel": "Cancelar",
"generate": "Gerar",
"name_label": "Nome do aplicativo",
"name_placeholder": "ex. Thunderbird, iPhone Mail",
"password_label": "Senha (deixe vazio para auto-gerar)",
"password_placeholder": "Auto-gerada se vazio",
"added": "Senha de aplicativo criada",
"removed": "Senha de aplicativo removida",
"add_error": "Não foi possível criar a senha de aplicativo",
"remove_error": "Não foi possível remover a senha de aplicativo",
"none": "Nenhuma senha de aplicativo configurada"
},
"encryption": {
"section_title": "Criptografia em repouso",
"label": "Criptografia de e-mail",
"description": "Criptografe e-mails armazenados no servidor para maior privacidade",
"active": "Criptografia {type} habilitada",
"inactive": "Desabilitado",
"enabled": "Criptografia em repouso habilitada",
"disabled_success": "Criptografia em repouso desabilitada",
"error": "Não foi possível atualizar as configurações de criptografia"
}
},
"identities": {
"title": "Identidades de Envio",
"description": "Gerencie endereços de e-mail que você pode usar para enviar",
@@ -0,0 +1,424 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useAccountSecurityStore } from '../account-security-store';
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const defaultState = {
isStalwart: null,
isProbing: false,
otpEnabled: false,
appPasswords: [],
isLoadingAuth: false,
encryptionType: 'disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
quota: 0,
roles: [],
isLoadingPrincipal: false,
isSaving: false,
error: null,
};
describe('AccountSecurityStore', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
useAccountSecurityStore.setState(defaultState);
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('probe', () => {
it('sets isStalwart to true when probe succeeds', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: true }));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(true);
expect(useAccountSecurityStore.getState().isStalwart).toBe(true);
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
});
it('sets isStalwart to false when probe returns false', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { isStalwart: false }));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(false);
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
});
it('sets isStalwart to false on network error', async () => {
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
const result = await useAccountSecurityStore.getState().probe();
expect(result).toBe(false);
expect(useAccountSecurityStore.getState().isStalwart).toBe(false);
expect(useAccountSecurityStore.getState().isProbing).toBe(false);
});
});
describe('fetchAuthInfo', () => {
it('populates auth info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: true, appPasswords: ['app1', 'app2'] } })
);
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.otpEnabled).toBe(true);
expect(state.appPasswords).toEqual(['app1', 'app2']);
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBeNull();
});
it('sets defaults when data fields are missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
});
it('sets error on HTTP failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500));
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBe('HTTP 500');
});
it('sets error on network failure', async () => {
fetchSpy.mockRejectedValueOnce(new Error('Connection refused'));
await useAccountSecurityStore.getState().fetchAuthInfo();
const state = useAccountSecurityStore.getState();
expect(state.isLoadingAuth).toBe(false);
expect(state.error).toBe('Connection refused');
});
});
describe('fetchCryptoInfo', () => {
it('populates crypto info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { type: 'pgp' } })
);
await useAccountSecurityStore.getState().fetchCryptoInfo();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('pgp');
expect(state.isLoadingCrypto).toBe(false);
});
it('defaults to disabled when type is missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await useAccountSecurityStore.getState().fetchCryptoInfo();
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
});
it('sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403));
await useAccountSecurityStore.getState().fetchCryptoInfo();
expect(useAccountSecurityStore.getState().error).toBe('HTTP 403');
});
});
describe('fetchPrincipal', () => {
it('populates principal info on success', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, {
data: {
description: 'John Doe',
emails: ['john@example.com', 'doe@example.com'],
quota: 5000000,
roles: ['user', 'admin'],
},
})
);
await useAccountSecurityStore.getState().fetchPrincipal();
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('John Doe');
expect(state.emails).toEqual(['john@example.com', 'doe@example.com']);
expect(state.quota).toBe(5000000);
expect(state.roles).toEqual(['user', 'admin']);
expect(state.isLoadingPrincipal).toBe(false);
});
it('handles single email string as array', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, {
data: { description: 'User', emails: 'single@example.com', quota: 0, roles: [] },
})
);
await useAccountSecurityStore.getState().fetchPrincipal();
expect(useAccountSecurityStore.getState().emails).toEqual(['single@example.com']);
});
it('handles missing emails gracefully', async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { description: 'User' } })
);
await useAccountSecurityStore.getState().fetchPrincipal();
expect(useAccountSecurityStore.getState().emails).toEqual([]);
});
it('sets defaults when fields are missing', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
await useAccountSecurityStore.getState().fetchPrincipal();
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
expect(state.quota).toBe(0);
expect(state.roles).toEqual([]);
});
});
describe('fetchAll', () => {
it('calls all three fetch methods in parallel', async () => {
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'smime' } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'Test', emails: [], quota: 0, roles: [] } }));
await useAccountSecurityStore.getState().fetchAll();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('smime');
expect(state.displayName).toBe('Test');
expect(state.isLoadingAuth).toBe(false);
expect(state.isLoadingCrypto).toBe(false);
expect(state.isLoadingPrincipal).toBe(false);
});
it('continues even if one fetch fails', async () => {
fetchSpy
.mockResolvedValueOnce(mockFetchResponse(500)) // auth fails
.mockResolvedValueOnce(mockFetchResponse(200, { data: { type: 'pgp' } }))
.mockResolvedValueOnce(mockFetchResponse(200, { data: { description: 'OK', emails: [], quota: 0, roles: [] } }));
await useAccountSecurityStore.getState().fetchAll();
const state = useAccountSecurityStore.getState();
expect(state.encryptionType).toBe('pgp');
expect(state.displayName).toBe('OK');
});
});
describe('changePassword', () => {
it('sends POST with currentPassword and newPassword', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { ok: true }));
await useAccountSecurityStore.getState().changePassword('oldpass', 'newpass123');
expect(fetchSpy).toHaveBeenCalledWith('/api/account/stalwart/password', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ currentPassword: 'oldpass', newPassword: 'newpass123' }),
}));
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
it('throws and sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { error: 'Current password is incorrect' }));
await expect(
useAccountSecurityStore.getState().changePassword('wrong', 'newpass123')
).rejects.toThrow('Current password is incorrect');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
expect(useAccountSecurityStore.getState().error).toBe('Current password is incorrect');
});
});
describe('updateDisplayName', () => {
it('sends PATCH and updates local state on success', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await useAccountSecurityStore.getState().updateDisplayName('New Name');
const state = useAccountSecurityStore.getState();
expect(state.displayName).toBe('New Name');
expect(state.isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
});
it('throws and sets error on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server error' }));
await expect(
useAccountSecurityStore.getState().updateDisplayName('Name')
).rejects.toThrow('Server error');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
});
describe('enableTotp', () => {
it('sends enableOtpAuth and returns TOTP URL', async () => {
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC';
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
const result = await useAccountSecurityStore.getState().enableTotp();
expect(result).toBe(totpUrl);
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'enableOtpAuth' }]);
});
it('throws and preserves otpEnabled=false on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'TOTP error' }));
await expect(
useAccountSecurityStore.getState().enableTotp()
).rejects.toThrow('TOTP error');
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
});
});
describe('disableTotp', () => {
it('sends disableOtpAuth and sets otpEnabled to false', async () => {
useAccountSecurityStore.setState({ otpEnabled: true });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await useAccountSecurityStore.getState().disableTotp();
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
});
describe('addAppPassword', () => {
it('sends addAppPassword and refreshes auth info', async () => {
// First call: POST addAppPassword
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
// Second call: fetchAuthInfo refresh
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['Thunderbird'] } })
);
await useAccountSecurityStore.getState().addAppPassword('Thunderbird', 'secret');
const state = useAccountSecurityStore.getState();
expect(state.appPasswords).toEqual(['Thunderbird']);
expect(state.isSaving).toBe(false);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret' }]);
});
it('throws on failure', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Server down' }));
await expect(
useAccountSecurityStore.getState().addAppPassword('App', 'pass')
).rejects.toThrow('Server down');
});
});
describe('removeAppPassword', () => {
it('sends removeAppPassword and refreshes auth info', async () => {
useAccountSecurityStore.setState({ appPasswords: ['Thunderbird', 'iPhone'] });
// First call: POST removeAppPassword
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
// Second call: fetchAuthInfo refresh
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: ['iPhone'] } })
);
await useAccountSecurityStore.getState().removeAppPassword('Thunderbird');
expect(useAccountSecurityStore.getState().appPasswords).toEqual(['iPhone']);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
});
});
describe('updateEncryption', () => {
it('sends crypto settings and updates local encryptionType', async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
await useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' });
expect(useAccountSecurityStore.getState().encryptionType).toBe('pgp');
expect(useAccountSecurityStore.getState().isSaving).toBe(false);
});
it('throws on failure without changing encryptionType', async () => {
useAccountSecurityStore.setState({ encryptionType: 'disabled' });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(500, { error: 'Encryption error' }));
await expect(
useAccountSecurityStore.getState().updateEncryption({ type: 'pgp' })
).rejects.toThrow('Encryption error');
expect(useAccountSecurityStore.getState().encryptionType).toBe('disabled');
});
});
describe('clearState', () => {
it('resets all state to defaults', () => {
useAccountSecurityStore.setState({
isStalwart: true,
otpEnabled: true,
appPasswords: ['app1'],
encryptionType: 'pgp',
displayName: 'Test User',
emails: ['test@example.com'],
quota: 5000000,
roles: ['admin'],
error: 'some error',
});
useAccountSecurityStore.getState().clearState();
const state = useAccountSecurityStore.getState();
expect(state.isStalwart).toBeNull();
expect(state.isProbing).toBe(false);
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
expect(state.encryptionType).toBe('disabled');
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
expect(state.quota).toBe(0);
expect(state.roles).toEqual([]);
expect(state.isSaving).toBe(false);
expect(state.error).toBeNull();
});
});
});
+353
View File
@@ -0,0 +1,353 @@
import { create } from 'zustand';
import { debug } from '@/lib/debug';
interface AccountSecurityState {
// Detection
isStalwart: boolean | null; // null = not yet probed
isProbing: boolean;
// Auth info
otpEnabled: boolean;
appPasswords: string[];
isLoadingAuth: boolean;
// Crypto info
encryptionType: string;
isLoadingCrypto: boolean;
// Principal info
displayName: string;
emails: string[];
quota: number;
roles: string[];
isLoadingPrincipal: boolean;
// Operation states
isSaving: boolean;
error: string | null;
// Actions
probe: () => Promise<boolean>;
fetchAuthInfo: () => Promise<void>;
fetchCryptoInfo: () => Promise<void>;
fetchPrincipal: () => Promise<void>;
fetchAll: () => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
updateDisplayName: (displayName: string) => Promise<void>;
enableTotp: () => Promise<string>;
disableTotp: () => Promise<void>;
addAppPassword: (name: string, password: string) => Promise<void>;
removeAppPassword: (name: string) => Promise<void>;
updateEncryption: (settings: { type: string; algo?: string; certs?: string }) => Promise<void>;
clearState: () => void;
}
/**
* Get authorization headers for API requests.
* Returns headers object with auth credentials.
*/
function getApiHeaders(): Record<string, string> {
// We rely on the proxy routes which read from session cookie
// No additional headers needed for cookie-based auth
return {};
}
export const useAccountSecurityStore = create<AccountSecurityState>()((set, get) => ({
isStalwart: null,
isProbing: false,
otpEnabled: false,
appPasswords: [],
isLoadingAuth: false,
encryptionType: 'disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
quota: 0,
roles: [],
isLoadingPrincipal: false,
isSaving: false,
error: null,
probe: async () => {
set({ isProbing: true });
try {
const response = await fetch('/api/account/stalwart/probe', {
headers: getApiHeaders(),
});
const data = await response.json();
const isStalwart = data.isStalwart === true;
set({ isStalwart, isProbing: false });
return isStalwart;
} catch (error) {
debug.error('Stalwart probe failed:', error);
set({ isStalwart: false, isProbing: false });
return false;
}
},
fetchAuthInfo: async () => {
set({ isLoadingAuth: true, error: null });
try {
const response = await fetch('/api/account/stalwart/auth', {
headers: getApiHeaders(),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
set({
otpEnabled: data.data?.otpEnabled ?? false,
appPasswords: data.data?.appPasswords ?? [],
isLoadingAuth: false,
});
} catch (error) {
debug.error('Failed to fetch auth info:', error);
set({
isLoadingAuth: false,
error: error instanceof Error ? error.message : 'Failed to fetch auth info',
});
}
},
fetchCryptoInfo: async () => {
set({ isLoadingCrypto: true, error: null });
try {
const response = await fetch('/api/account/stalwart/crypto', {
headers: getApiHeaders(),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
set({
encryptionType: data.data?.type ?? 'disabled',
isLoadingCrypto: false,
});
} catch (error) {
debug.error('Failed to fetch crypto info:', error);
set({
isLoadingCrypto: false,
error: error instanceof Error ? error.message : 'Failed to fetch crypto info',
});
}
},
fetchPrincipal: async () => {
set({ isLoadingPrincipal: true, error: null });
try {
const response = await fetch('/api/account/stalwart/principal', {
headers: getApiHeaders(),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const principal = data.data;
set({
displayName: principal?.description ?? '',
emails: Array.isArray(principal?.emails) ? principal.emails : principal?.emails ? [principal.emails] : [],
quota: principal?.quota ?? 0,
roles: principal?.roles ?? [],
isLoadingPrincipal: false,
});
} catch (error) {
debug.error('Failed to fetch principal:', error);
set({
isLoadingPrincipal: false,
error: error instanceof Error ? error.message : 'Failed to fetch principal',
});
}
},
fetchAll: async () => {
const { fetchAuthInfo, fetchCryptoInfo, fetchPrincipal } = get();
await Promise.allSettled([fetchAuthInfo(), fetchCryptoInfo(), fetchPrincipal()]);
},
changePassword: async (currentPassword, newPassword) => {
set({ isSaving: true, error: null });
try {
const response = await fetch('/api/account/stalwart/password', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `HTTP ${response.status}`);
}
set({ isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to change password',
});
throw error;
}
},
updateDisplayName: async (displayName) => {
set({ isSaving: true, error: null });
try {
const response = await fetch('/api/account/stalwart/principal', {
method: 'PATCH',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([
{ action: 'set', field: 'description', value: displayName },
]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `HTTP ${response.status}`);
}
set({ displayName, isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to update display name',
});
throw error;
}
},
enableTotp: async () => {
set({ isSaving: true, error: null });
try {
const response = await fetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
const data = await response.json();
set({ otpEnabled: true, isSaving: false });
return data.data;
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to enable TOTP',
});
throw error;
}
},
disableTotp: async () => {
set({ isSaving: true, error: null });
try {
const response = await fetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
set({ otpEnabled: false, isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to disable TOTP',
});
throw error;
}
},
addAppPassword: async (name, password) => {
set({ isSaving: true, error: null });
try {
const response = await fetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
// Refresh auth info to get updated app passwords list
await get().fetchAuthInfo();
set({ isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to add app password',
});
throw error;
}
},
removeAppPassword: async (name) => {
set({ isSaving: true, error: null });
try {
const response = await fetch('/api/account/stalwart/auth', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
// Refresh auth info to get updated app passwords list
await get().fetchAuthInfo();
set({ isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to remove app password',
});
throw error;
}
},
updateEncryption: async (settings) => {
set({ isSaving: true, error: null });
try {
const response = await fetch('/api/account/stalwart/crypto', {
method: 'POST',
headers: { ...getApiHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || data.details || `HTTP ${response.status}`);
}
set({ encryptionType: settings.type, isSaving: false });
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'Failed to update encryption',
});
throw error;
}
},
clearState: () => set({
isStalwart: null,
isProbing: false,
otpEnabled: false,
appPasswords: [],
isLoadingAuth: false,
encryptionType: 'disabled',
isLoadingCrypto: false,
displayName: '',
emails: [],
quota: 0,
roles: [],
isLoadingPrincipal: false,
isSaving: false,
error: null,
}),
}));
+27 -11
View File
@@ -6,6 +6,7 @@ type Theme = 'light' | 'dark' | 'system';
interface ThemeState {
theme: Theme;
resolvedTheme: 'light' | 'dark';
hydrated: boolean;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
initializeTheme: () => void;
@@ -20,7 +21,6 @@ const applyTheme = (theme: 'light' | 'dark') => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// Ensure both classes are handled properly
if (theme === 'dark') {
root.classList.remove('light');
root.classList.add('dark');
@@ -29,15 +29,20 @@ const applyTheme = (theme: 'light' | 'dark') => {
root.classList.add('light');
}
// Store in localStorage for immediate access
// Also update color-scheme for native elements (scrollbars, form controls)
root.style.colorScheme = theme;
localStorage.setItem('theme-applied', theme);
};
let mediaQueryCleanup: (() => void) | null = null;
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
resolvedTheme: 'light',
hydrated: false,
setTheme: (theme) => {
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
@@ -57,9 +62,14 @@ export const useThemeStore = create<ThemeState>()(
const { theme } = get();
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
applyTheme(resolvedTheme);
set({ resolvedTheme });
set({ resolvedTheme, hydrated: true });
// Clean up previous listener if any
if (mediaQueryCleanup) {
mediaQueryCleanup();
mediaQueryCleanup = null;
}
// Listen for system theme changes
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
@@ -71,19 +81,25 @@ export const useThemeStore = create<ThemeState>()(
}
};
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
} else {
// Fallback for older browsers
mediaQuery.addListener(handleChange);
}
mediaQuery.addEventListener('change', handleChange);
mediaQueryCleanup = () => mediaQuery.removeEventListener('change', handleChange);
}
},
}),
{
name: 'theme-storage',
partialize: (state) => ({ theme: state.theme }),
onRehydrateStorage: () => {
return (state) => {
if (state) {
// Re-apply theme immediately after rehydration
const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme;
applyTheme(resolvedTheme);
state.resolvedTheme = resolvedTheme;
state.hydrated = true;
}
};
},
}
)
);