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
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
/specifications/
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
+366
-278
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -10,13 +10,19 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { cn } from "@/lib/utils";
|
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 { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.1.2";
|
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() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
@@ -35,6 +41,7 @@ export default function LoginPage() {
|
|||||||
const [sessionExpired, setSessionExpired] = useState(false);
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [shakeError, setShakeError] = useState(false);
|
const [shakeError, setShakeError] = useState(false);
|
||||||
|
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
||||||
|
|
||||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
@@ -49,6 +56,7 @@ export default function LoginPage() {
|
|||||||
const justSelectedSuggestion = useRef(false);
|
const justSelectedSuggestion = useRef(false);
|
||||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||||
const prevError = useRef<string | null>(null);
|
const prevError = useRef<string | null>(null);
|
||||||
|
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
@@ -138,6 +146,9 @@ export default function LoginPage() {
|
|||||||
inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
}
|
}
|
||||||
|
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowThemeMenu(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
@@ -157,9 +168,14 @@ export default function LoginPage() {
|
|||||||
});
|
});
|
||||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
||||||
|
|
||||||
|
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
||||||
|
setTheme(newTheme);
|
||||||
|
setShowThemeMenu(false);
|
||||||
|
}, [setTheme]);
|
||||||
|
|
||||||
if (configLoading) {
|
if (configLoading) {
|
||||||
return (
|
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">
|
<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" />
|
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto" />
|
||||||
<span className="sr-only">{t("loading")}</span>
|
<span className="sr-only">{t("loading")}</span>
|
||||||
@@ -170,15 +186,17 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (configError) {
|
if (configError) {
|
||||||
return (
|
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">
|
<div className="w-full max-w-md 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">
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl p-8">
|
||||||
<AlertCircle className="w-10 h-10 text-red-500" />
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -186,15 +204,17 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
return (
|
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">
|
<div className="w-full max-w-md 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">
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl p-8">
|
||||||
<AlertCircle className="w-10 h-10 text-red-500" />
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -334,287 +354,355 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const themeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
|
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
||||||
const ThemeIcon = themeIcon;
|
const CurrentThemeIcon = currentThemeOption.icon;
|
||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* Theme toggle - top right, dropdown style */}
|
||||||
<div className="absolute top-4 right-4">
|
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||||
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
|
className={cn(
|
||||||
setTheme(next);
|
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
|
||||||
}}
|
showThemeMenu
|
||||||
className="p-2.5 rounded-lg bg-secondary/60 hover:bg-secondary border border-border/50 text-muted-foreground hover:text-foreground transition-colors"
|
? "bg-secondary border-border text-foreground shadow-md"
|
||||||
aria-label={`Theme: ${theme}`}
|
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
|
||||||
title={`Theme: ${theme}`}
|
)}
|
||||||
|
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>
|
</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>
|
||||||
|
|
||||||
<div className="w-full max-w-sm mx-auto px-4">
|
<div className="w-full max-w-[400px] mx-auto">
|
||||||
{/* Logo */}
|
{/* Card container */}
|
||||||
<div className="text-center mb-12">
|
<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">
|
||||||
<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">
|
{/* Header section with logo */}
|
||||||
<Mail className="w-10 h-10 text-primary" />
|
<div className="px-8 pt-10 pb-6 text-center">
|
||||||
</div>
|
<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">
|
||||||
<h1 className="text-3xl font-light text-foreground tracking-tight">
|
<Mail className="w-8 h-8 text-primary-foreground" />
|
||||||
{appName}
|
</div>
|
||||||
</h1>
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
</div>
|
{appName}
|
||||||
|
</h1>
|
||||||
{/* Session Expired Banner */}
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
{sessionExpired && (
|
{t("title") !== appName ? t("title") : "Sign in to your account"}
|
||||||
<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")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dev Mode: One-click login */}
|
{/* Form section */}
|
||||||
{devMode ? (
|
<div className="px-8 pb-8">
|
||||||
<div className="space-y-4">
|
{/* Session Expired Banner */}
|
||||||
<Button
|
{sessionExpired && (
|
||||||
type="button"
|
<div
|
||||||
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
|
className="mb-5 p-3.5 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-start gap-3"
|
||||||
onClick={handleDevLogin}
|
role="status"
|
||||||
disabled={isLoading}
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<Info className="w-4.5 h-4.5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1 leading-relaxed">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
{t("session_expired")}
|
||||||
{t("signing_in")}
|
</p>
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setSessionExpired(false)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground transition-colors"
|
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0"
|
||||||
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
aria-label={t("dismiss")}
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
<EyeOff className="w-4.5 h-4.5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4.5 h-4.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{/* Error Message */}
|
||||||
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-start gap-2">
|
{error && (
|
||||||
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
<div className={cn(
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||||
{t("error.oauth_discovery_failed")}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version number - bottom center */}
|
{/* Dev Mode: One-click login */}
|
||||||
<div className="absolute bottom-4 text-xs text-muted-foreground/50">
|
{devMode ? (
|
||||||
v{APP_VERSION}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-6
@@ -52,6 +52,9 @@ export default function Home() {
|
|||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
const [showAdvancedFields, setShowAdvancedFields] = 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
|
// Mobile conversation view state
|
||||||
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
||||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||||
@@ -834,7 +837,8 @@ export default function Home() {
|
|||||||
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
|
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
// Mobile/Tablet: fixed overlay
|
||||||
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
|
"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",
|
"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) */}
|
{/* Sidebar resize handle (desktop only, hidden when collapsed) */}
|
||||||
{!isMobile && !isTablet && !sidebarCollapsed && (
|
{!isMobile && !isTablet && !sidebarCollapsed && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResize={(delta) => setSidebarWidth(sidebarWidth + delta)}
|
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
|
||||||
onResizeEnd={persistColumnWidths}
|
onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)}
|
||||||
|
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
|
||||||
onDoubleClick={resetSidebarWidth}
|
onDoubleClick={resetSidebarWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -883,7 +888,7 @@ export default function Home() {
|
|||||||
isMobile && activeView !== "list" && "max-md:hidden",
|
isMobile && activeView !== "list" && "max-md:hidden",
|
||||||
// Tablet/Desktop: fixed width with collapse animation
|
// Tablet/Desktop: fixed width with collapse animation
|
||||||
"md:flex-shrink-0 md:shadow-sm",
|
"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
|
// Tablet: collapse when email selected
|
||||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
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) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResize={(delta) => setEmailListWidth(emailListWidth + delta)}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResizeEnd={persistColumnWidths}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
|
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
|
||||||
onDoubleClick={resetEmailListWidth}
|
onDoubleClick={resetEmailListWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,19 +16,22 @@ import { TemplateSettings } from '@/components/settings/template-settings';
|
|||||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||||
|
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||||
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { cn } from '@/lib/utils';
|
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() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations('settings');
|
const t = useTranslations('settings');
|
||||||
const { client, isAuthenticated, logout } = useAuthStore();
|
const { client, isAuthenticated, logout } = useAuthStore();
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
@@ -52,6 +55,7 @@ export default function SettingsPage() {
|
|||||||
{ id: 'appearance', label: t('tabs.appearance') },
|
{ id: 'appearance', label: t('tabs.appearance') },
|
||||||
{ id: 'email', label: t('tabs.email') },
|
{ id: 'email', label: t('tabs.email') },
|
||||||
{ id: 'account', label: t('tabs.account') },
|
{ id: 'account', label: t('tabs.account') },
|
||||||
|
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security') }] : []),
|
||||||
{ id: 'identities', label: t('tabs.identities') },
|
{ id: 'identities', label: t('tabs.identities') },
|
||||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
|
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []),
|
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []),
|
||||||
@@ -76,6 +80,7 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||||
{activeTab === 'email' && <EmailSettings />}
|
{activeTab === 'email' && <EmailSettings />}
|
||||||
{activeTab === 'account' && <AccountSettings />}
|
{activeTab === 'account' && <AccountSettings />}
|
||||||
|
{activeTab === 'security' && <AccountSecuritySettings />}
|
||||||
{activeTab === 'identities' && <IdentitySettings />}
|
{activeTab === 'identities' && <IdentitySettings />}
|
||||||
{activeTab === 'vacation' && <VacationSettings />}
|
{activeTab === 'vacation' && <VacationSettings />}
|
||||||
{activeTab === 'calendar' && <CalendarSettings />}
|
{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 || '',
|
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
||||||
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!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',
|
devMode: process.env.DEV_MOCK_JMAP === 'true',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ResizeHandleProps {
|
interface ResizeHandleProps {
|
||||||
|
onResizeStart?: () => void;
|
||||||
onResize: (delta: number) => void;
|
onResize: (delta: number) => void;
|
||||||
onResizeEnd?: () => void;
|
onResizeEnd?: () => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
@@ -12,17 +13,18 @@ interface ResizeHandleProps {
|
|||||||
|
|
||||||
const KEYBOARD_STEP = 10;
|
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 isDragging = useRef(false);
|
||||||
const lastX = useRef(0);
|
const startX = useRef(0);
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
lastX.current = e.clientX;
|
startX.current = e.clientX;
|
||||||
document.body.style.cursor = "col-resize";
|
document.body.style.cursor = "col-resize";
|
||||||
document.body.style.userSelect = "none";
|
document.body.style.userSelect = "none";
|
||||||
}, []);
|
onResizeStart?.();
|
||||||
|
}, [onResizeStart]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
let delta = 0;
|
let delta = 0;
|
||||||
@@ -37,8 +39,7 @@ export function ResizeHandle({ onResize, onResizeEnd, onDoubleClick, className }
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isDragging.current) return;
|
if (!isDragging.current) return;
|
||||||
const delta = e.clientX - lastX.current;
|
const delta = e.clientX - startX.current;
|
||||||
lastX.current = e.clientX;
|
|
||||||
onResize(delta);
|
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;
|
oauthIssuerUrl: string;
|
||||||
rememberMeEnabled: boolean;
|
rememberMeEnabled: boolean;
|
||||||
settingsSyncEnabled: boolean;
|
settingsSyncEnabled: boolean;
|
||||||
|
stalwartFeaturesEnabled: boolean;
|
||||||
devMode: boolean;
|
devMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ export function useConfig(): AppConfig {
|
|||||||
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
|
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
|
||||||
rememberMeEnabled: configCache?.rememberMeEnabled || false,
|
rememberMeEnabled: configCache?.rememberMeEnabled || false,
|
||||||
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
|
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
|
||||||
|
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
|
||||||
devMode: configCache?.devMode || false,
|
devMode: configCache?.devMode || false,
|
||||||
isLoading: !configCache,
|
isLoading: !configCache,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -84,6 +86,7 @@ export function useConfig(): AppConfig {
|
|||||||
oauthIssuerUrl: configCache.oauthIssuerUrl,
|
oauthIssuerUrl: configCache.oauthIssuerUrl,
|
||||||
rememberMeEnabled: configCache.rememberMeEnabled,
|
rememberMeEnabled: configCache.rememberMeEnabled,
|
||||||
settingsSyncEnabled: configCache.settingsSyncEnabled,
|
settingsSyncEnabled: configCache.settingsSyncEnabled,
|
||||||
|
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
|
||||||
devMode: configCache.devMode,
|
devMode: configCache.devMode,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -101,6 +104,7 @@ export function useConfig(): AppConfig {
|
|||||||
oauthIssuerUrl: data.oauthIssuerUrl,
|
oauthIssuerUrl: data.oauthIssuerUrl,
|
||||||
rememberMeEnabled: data.rememberMeEnabled,
|
rememberMeEnabled: data.rememberMeEnabled,
|
||||||
settingsSyncEnabled: data.settingsSyncEnabled,
|
settingsSyncEnabled: data.settingsSyncEnabled,
|
||||||
|
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
|
||||||
devMode: data.devMode,
|
devMode: data.devMode,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
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",
|
"filters": "Filter",
|
||||||
"templates": "Vorlagen",
|
"templates": "Vorlagen",
|
||||||
"folders": "Ordner",
|
"folders": "Ordner",
|
||||||
"keywords": "Schlüsselwörter"
|
"keywords": "Schlüsselwörter",
|
||||||
|
"security": "Sicherheit"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
@@ -663,6 +664,70 @@
|
|||||||
"value": "{time}"
|
"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": {
|
"identities": {
|
||||||
"title": "Sendeidentitäten",
|
"title": "Sendeidentitäten",
|
||||||
"description": "Verwalten Sie E-Mail-Adressen, von denen Sie senden können",
|
"description": "Verwalten Sie E-Mail-Adressen, von denen Sie senden können",
|
||||||
|
|||||||
+66
-1
@@ -464,7 +464,8 @@
|
|||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"folders": "Folders",
|
"folders": "Folders",
|
||||||
"keywords": "Keywords"
|
"keywords": "Keywords",
|
||||||
|
"security": "Security"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
@@ -678,6 +679,70 @@
|
|||||||
"value": "{time}"
|
"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": {
|
"identities": {
|
||||||
"title": "Sending Identities",
|
"title": "Sending Identities",
|
||||||
"description": "Manage email addresses you can send from",
|
"description": "Manage email addresses you can send from",
|
||||||
|
|||||||
+66
-1
@@ -449,7 +449,8 @@
|
|||||||
"filters": "Filtros",
|
"filters": "Filtros",
|
||||||
"templates": "Plantillas",
|
"templates": "Plantillas",
|
||||||
"folders": "Carpetas",
|
"folders": "Carpetas",
|
||||||
"keywords": "Palabras clave"
|
"keywords": "Palabras clave",
|
||||||
|
"security": "Seguridad"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
@@ -663,6 +664,70 @@
|
|||||||
"value": "{time}"
|
"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": {
|
"identities": {
|
||||||
"title": "Identidades de Envío",
|
"title": "Identidades de Envío",
|
||||||
"description": "Administre las direcciones de correo desde las que puede enviar",
|
"description": "Administre las direcciones de correo desde las que puede enviar",
|
||||||
|
|||||||
+66
-1
@@ -449,7 +449,8 @@
|
|||||||
"filters": "Filtres",
|
"filters": "Filtres",
|
||||||
"templates": "Modèles",
|
"templates": "Modèles",
|
||||||
"folders": "Dossiers",
|
"folders": "Dossiers",
|
||||||
"keywords": "Mots-clés"
|
"keywords": "Mots-clés",
|
||||||
|
"security": "Sécurité"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
@@ -663,6 +664,70 @@
|
|||||||
"value": "{time}"
|
"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": {
|
"identities": {
|
||||||
"title": "Identités d'envoi",
|
"title": "Identités d'envoi",
|
||||||
"description": "Gérer les adresses email depuis lesquelles vous pouvez envoyer",
|
"description": "Gérer les adresses email depuis lesquelles vous pouvez envoyer",
|
||||||
|
|||||||
+66
-1
@@ -449,7 +449,8 @@
|
|||||||
"filters": "Filtri",
|
"filters": "Filtri",
|
||||||
"templates": "Modelli",
|
"templates": "Modelli",
|
||||||
"folders": "Cartelle",
|
"folders": "Cartelle",
|
||||||
"keywords": "Parole chiave"
|
"keywords": "Parole chiave",
|
||||||
|
"security": "Sicurezza"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Generale",
|
"general": "Generale",
|
||||||
@@ -663,6 +664,70 @@
|
|||||||
"value": "{time}"
|
"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": {
|
"identities": {
|
||||||
"title": "Identità di invio",
|
"title": "Identità di invio",
|
||||||
"description": "Gestisci gli indirizzi email da cui puoi inviare",
|
"description": "Gestisci gli indirizzi email da cui puoi inviare",
|
||||||
|
|||||||
+66
-1
@@ -449,7 +449,8 @@
|
|||||||
"filters": "フィルター",
|
"filters": "フィルター",
|
||||||
"templates": "テンプレート",
|
"templates": "テンプレート",
|
||||||
"folders": "フォルダー",
|
"folders": "フォルダー",
|
||||||
"keywords": "キーワード"
|
"keywords": "キーワード",
|
||||||
|
"security": "セキュリティ"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "一般",
|
"general": "一般",
|
||||||
@@ -663,6 +664,70 @@
|
|||||||
"value": "{time}"
|
"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": {
|
"identities": {
|
||||||
"title": "送信者情報",
|
"title": "送信者情報",
|
||||||
"description": "送信に使用するメールアドレスを管理",
|
"description": "送信に使用するメールアドレスを管理",
|
||||||
|
|||||||
+66
-1
@@ -449,7 +449,8 @@
|
|||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"templates": "Sjablonen",
|
"templates": "Sjablonen",
|
||||||
"folders": "Mappen",
|
"folders": "Mappen",
|
||||||
"keywords": "Sleutelwoorden"
|
"keywords": "Sleutelwoorden",
|
||||||
|
"security": "Beveiliging"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Algemeen",
|
"general": "Algemeen",
|
||||||
@@ -663,6 +664,70 @@
|
|||||||
"value": "{time}"
|
"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": {
|
"identities": {
|
||||||
"title": "Verzendidentiteiten",
|
"title": "Verzendidentiteiten",
|
||||||
"description": "Beheer e-mailadressen van waaruit je kunt verzenden",
|
"description": "Beheer e-mailadressen van waaruit je kunt verzenden",
|
||||||
|
|||||||
+66
-1
@@ -449,7 +449,8 @@
|
|||||||
"filters": "Filtros",
|
"filters": "Filtros",
|
||||||
"templates": "Modelos",
|
"templates": "Modelos",
|
||||||
"folders": "Pastas",
|
"folders": "Pastas",
|
||||||
"keywords": "Palavras-chave"
|
"keywords": "Palavras-chave",
|
||||||
|
"security": "Segurança"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
@@ -663,6 +664,70 @@
|
|||||||
"value": "{time}"
|
"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": {
|
"identities": {
|
||||||
"title": "Identidades de Envio",
|
"title": "Identidades de Envio",
|
||||||
"description": "Gerencie endereços de e-mail que você pode usar para enviar",
|
"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 {
|
interface ThemeState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
resolvedTheme: 'light' | 'dark';
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
hydrated: boolean;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
initializeTheme: () => void;
|
initializeTheme: () => void;
|
||||||
@@ -20,7 +21,6 @@ const applyTheme = (theme: 'light' | 'dark') => {
|
|||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
// Ensure both classes are handled properly
|
|
||||||
if (theme === 'dark') {
|
if (theme === 'dark') {
|
||||||
root.classList.remove('light');
|
root.classList.remove('light');
|
||||||
root.classList.add('dark');
|
root.classList.add('dark');
|
||||||
@@ -29,15 +29,20 @@ const applyTheme = (theme: 'light' | 'dark') => {
|
|||||||
root.classList.add('light');
|
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);
|
localStorage.setItem('theme-applied', theme);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mediaQueryCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
export const useThemeStore = create<ThemeState>()(
|
export const useThemeStore = create<ThemeState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
resolvedTheme: 'light',
|
resolvedTheme: 'light',
|
||||||
|
hydrated: false,
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||||
@@ -57,9 +62,14 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
const { theme } = get();
|
const { theme } = get();
|
||||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||||
applyTheme(resolvedTheme);
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
@@ -71,19 +81,25 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modern browsers
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
if (mediaQuery.addEventListener) {
|
mediaQueryCleanup = () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
} else {
|
|
||||||
// Fallback for older browsers
|
|
||||||
mediaQuery.addListener(handleChange);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'theme-storage',
|
name: 'theme-storage',
|
||||||
partialize: (state) => ({ theme: state.theme }),
|
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