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:
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ResizeHandleProps {
|
||||
onResizeStart?: () => void;
|
||||
onResize: (delta: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
@@ -12,17 +13,18 @@ interface ResizeHandleProps {
|
||||
|
||||
const KEYBOARD_STEP = 10;
|
||||
|
||||
export function ResizeHandle({ onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
|
||||
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
|
||||
const isDragging = useRef(false);
|
||||
const lastX = useRef(0);
|
||||
const startX = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
lastX.current = e.clientX;
|
||||
startX.current = e.clientX;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
onResizeStart?.();
|
||||
}, [onResizeStart]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
let delta = 0;
|
||||
@@ -37,8 +39,7 @@ export function ResizeHandle({ onResize, onResizeEnd, onDoubleClick, className }
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const delta = e.clientX - lastX.current;
|
||||
lastX.current = e.clientX;
|
||||
const delta = e.clientX - startX.current;
|
||||
onResize(delta);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { useAccountSecurityStore } from '@/stores/account-security-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function PasswordChangeSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { changePassword, isSaving } = useAccountSecurityStore();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t('password.error_min_length'));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t('password.error_mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
toast.success(t('password.success'));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('password.error_generic');
|
||||
setError(msg);
|
||||
toast.error(t('password.error_title'), msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Key className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('password.title')}</h4>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showCurrent ? 'text' : 'password'}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('password.new')}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNew ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showNew ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('password.confirm')}</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isSaving || !currentPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
{t('password.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayNameSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { displayName, updateDisplayName, isSaving, isLoadingPrincipal } = useAccountSecurityStore();
|
||||
const [name, setName] = useState(displayName);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(displayName);
|
||||
}, [displayName]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateDisplayName(name);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
toast.success(t('display_name.success'));
|
||||
} catch (err) {
|
||||
toast.error(t('display_name.error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingPrincipal) {
|
||||
return (
|
||||
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || name === displayName}
|
||||
>
|
||||
{saved ? <Check className="w-4 h-4" /> : isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : t('display_name.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TotpSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [totpUrl, setTotpUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleToggle = async (enable: boolean) => {
|
||||
try {
|
||||
if (enable) {
|
||||
const url = await enableTotp();
|
||||
setTotpUrl(url);
|
||||
toast.success(t('totp.enabled'));
|
||||
} else {
|
||||
await disableTotp();
|
||||
setTotpUrl(null);
|
||||
toast.success(t('totp.disabled'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
enable ? t('totp.enable_error') : t('totp.disable_error'),
|
||||
err instanceof Error ? err.message : undefined
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (totpUrl) {
|
||||
navigator.clipboard.writeText(totpUrl).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingAuth) {
|
||||
return (
|
||||
<SettingItem label={t('totp.label')} description={t('totp.description')}>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SettingItem label={t('totp.label')} description={t('totp.description')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ToggleSwitch
|
||||
checked={otpEnabled}
|
||||
onChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
)}
|
||||
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
|
||||
{otpEnabled ? t('totp.active') : t('totp.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{totpUrl && (
|
||||
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
|
||||
{totpUrl}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppPasswordsSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const generatePassword = useCallback(() => {
|
||||
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let result = '';
|
||||
const array = new Uint8Array(24);
|
||||
crypto.getRandomValues(array);
|
||||
for (const byte of array) {
|
||||
result += chars[byte % chars.length];
|
||||
}
|
||||
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
|
||||
return result.match(/.{1,4}/g)?.join('-') ?? result;
|
||||
}, []);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
|
||||
const password = newPassword || generatePassword();
|
||||
|
||||
try {
|
||||
await addAppPassword(newName.trim(), password);
|
||||
setNewName('');
|
||||
setNewPassword('');
|
||||
setShowAdd(false);
|
||||
toast.success(t('app_passwords.added'));
|
||||
} catch (err) {
|
||||
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
try {
|
||||
await removeAppPassword(name);
|
||||
toast.success(t('app_passwords.removed'));
|
||||
} catch (err) {
|
||||
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingAuth) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
|
||||
</div>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
{t('app_passwords.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
|
||||
|
||||
{showAdd && (
|
||||
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t('app_passwords.name_placeholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={t('app_passwords.password_placeholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
|
||||
{t('app_passwords.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t('app_passwords.create')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowAdd(false)}>
|
||||
{t('app_passwords.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{appPasswords.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{appPasswords.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
|
||||
<span className="text-sm text-foreground">{name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(name)}
|
||||
disabled={isSaving}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EncryptionSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore();
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
if (enabled) {
|
||||
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
|
||||
toast.success(t('encryption.enabled'));
|
||||
} else {
|
||||
await updateEncryption({ type: 'disabled' });
|
||||
toast.success(t('encryption.disabled_success'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingCrypto) {
|
||||
return (
|
||||
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
const isEnabled = encryptionType !== 'disabled';
|
||||
|
||||
return (
|
||||
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ToggleSwitch
|
||||
checked={isEnabled}
|
||||
onChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
)}
|
||||
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
|
||||
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSecuritySettings() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { isStalwart, isProbing, probe, fetchAll } = useAccountSecurityStore();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && isStalwart === null) {
|
||||
probe().then((detected) => {
|
||||
if (detected) {
|
||||
fetchAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, isStalwart, probe, fetchAll]);
|
||||
|
||||
if (isProbing) {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{t('detecting')}</span>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (isStalwart === false) {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="space-y-6">
|
||||
{/* Password Change */}
|
||||
<PasswordChangeSection />
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Display Name */}
|
||||
<DisplayNameSection />
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Two-Factor Authentication */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
|
||||
</div>
|
||||
<TotpSection />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* App Passwords */}
|
||||
<AppPasswordsSection />
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Encryption at Rest */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lock className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
|
||||
</div>
|
||||
<EncryptionSection />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user