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:
@@ -23,6 +23,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/specifications/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
+366
-278
@@ -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
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user