Files
webmail/components/settings/account-settings.tsx
T
Linus Rath f189e0bab6 fix: refresh account display name from the Stalwart principal on login/restore/switch #900
Stalwart seeds the default identity name from the principal "Full name" only
once, and addAccount never rewrote displayName after first login, so an admin
rename never showed up in Bulwark. Read the live name via x:Account/get
through the passthrough (falling back to the identity name elsewhere) and
prefer the synced registry name in account settings.
2026-08-25 18:45:44 +02:00

434 lines
16 KiB
TypeScript

"use client";
import { useState, useRef, useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import type { SharedAccount } from '@/lib/jmap/types';
import { SettingsSection, SettingItem } from './settings-section';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useRouter } from '@/i18n/navigation';
import { getMaxAccounts } from '@/lib/account-utils';
import { formatFileSize, cn } from '@/lib/utils';
function hostnameOf(serverUrl: string): string {
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
}
// First scoped settings tab to land on for a shared account, by capability.
// Mirrors the scoped-tab gating in the settings page. null = nothing editable.
function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
if (caps.sieve) return 'filters';
if (caps.mail) return 'vacation';
if (caps.calendars) return 'calendar';
if (caps.contacts) return 'contacts';
return null;
}
export function AccountSettings() {
const t = useTranslations('settings.account');
const tCommon = useTranslations('common');
const router = useRouter();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
// Shared/group accounts delegated to this session (excludes the user's own
// primary account). These can be drilled into for scoped settings editing.
const sharedAccounts = useMemo<SharedAccount[]>(
() => (client?.getSharedAccounts() ?? []).filter((a) => !a.isPrimary),
[client],
);
const { quota } = useEmailStore();
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
const account = useAccountStore((s) => activeAccountId ? s.getAccountById(activeAccountId) : undefined);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
const quotaPercentage = quota && quota.total > 0 ? Math.min(Math.round((quota.used / quota.total) * 100), 100) : 0;
// `account.displayName` is refreshed from the server on every login/restore
// (Stalwart principal "Full name" when available - #900); the identity name
// is only a fallback until that entry exists.
const displayName = account?.displayName || primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
const email = primaryIdentity?.email || account?.email || username;
const max = getMaxAccounts();
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
draggedIndexRef.current = index;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}, []);
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
}, []);
const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
setDragOverIndex(null);
const fromIndex = draggedIndexRef.current;
if (fromIndex === null || fromIndex === dropIndex) return;
const next = accounts.map((a) => a.id);
const [moved] = next.splice(fromIndex, 1);
next.splice(dropIndex, 0, moved);
reorderAccounts(next);
}, [accounts, reorderAccounts]);
const handleDragEnd = useCallback(() => {
draggedIndexRef.current = null;
setDragOverIndex(null);
}, []);
const moveAccount = useCallback((from: number, to: number) => {
if (to < 0 || to >= accounts.length || from === to) return;
const next = accounts.map((a) => a.id);
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
reorderAccounts(next);
}, [accounts, reorderAccounts]);
const handleSwitch = useCallback((id: string) => {
if (id === activeAccountId) return;
void switchAccount(id);
}, [activeAccountId, switchAccount]);
const handleAddAccount = useCallback(() => {
router.push(`/login?mode=add-account` as never);
}, [router]);
// Enter scoped settings mode for a shared/group account: set the managed
// account, then steer the settings panel to its first editable tab via the
// existing 'settings-tab-change' event the page already listens for.
const handleManageShared = useCallback((account: SharedAccount) => {
const tab = firstScopedTab(account.capabilities);
if (!tab) return;
setManagedAccount(account);
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: tab }));
}, [setManagedAccount]);
return (
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name */}
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName || tCommon('unknown')}</span>
</SettingItem>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{email || tCommon('unknown')}</span>
</SettingItem>
{/* Username / Login (show when it differs from email) */}
{username && username !== email && (
<SettingItem label={t('username_label')}>
<span className="text-sm text-foreground">{username}</span>
</SettingItem>
)}
{/* Authentication Method */}
<SettingItem label={t('auth_method_label')}>
<span className="text-sm text-foreground">
{authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')}
</span>
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || tCommon('unknown')}
</span>
</SettingItem>
{/* Storage */}
{quota && quota.total > 0 && (
<SettingItem
label={t('storage.label')}
description={t('storage.used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-foreground">
{t('storage.percentage', { percent: quotaPercentage })}
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${quotaPercentage}%` }}
/>
</div>
</div>
</SettingItem>
)}
{/* Demo mode indicator */}
{isDemoMode && (
<SettingItem label={t('account_type_label')}>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
{t('demo_account')}
</span>
</SettingItem>
)}
</SettingsSection>
{/* Logged-in accounts list */}
{accounts.length > 0 && (
<SettingsSection title={t('accounts.title')} description={t('accounts.description')}>
<div className="space-y-2">
{accounts.map((a, index) => (
<AccountRow
key={a.id}
account={a}
index={index}
isActive={a.id === activeAccountId}
isFirst={index === 0}
isLast={index === accounts.length - 1}
isDragOver={dragOverIndex === index}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onMoveUp={() => moveAccount(index, index - 1)}
onMoveDown={() => moveAccount(index, index + 1)}
onSwitch={() => handleSwitch(a.id)}
onSetDefault={() => setDefaultAccount(a.id)}
labels={{
active: t('accounts.active'),
default: t('accounts.default_badge'),
setDefault: t('accounts.set_default'),
switchTo: t('accounts.switch_to'),
moveUp: t('accounts.move_up'),
moveDown: t('accounts.move_down'),
dragHandle: t('accounts.drag_handle'),
}}
/>
))}
{accounts.length < max && (
<Button
variant="outline"
size="sm"
onClick={handleAddAccount}
className="w-full"
>
<Plus className="w-4 h-4 me-2" />
{t('accounts.add')}
</Button>
)}
</div>
</SettingsSection>
)}
{/* Shared / group accounts delegated to this session. Clicking one drills
into a scoped settings view (filters, vacation, calendars, contacts). */}
{sharedAccounts.length > 0 && (
<SettingsSection title={t('shared_accounts.title')} description={t('shared_accounts.description')}>
<div className="space-y-2">
{sharedAccounts.map((acc) => {
const editable = firstScopedTab(acc.capabilities) !== null;
return (
<button
key={acc.id}
type="button"
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-start transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
<Avatar
name={acc.name}
size="sm"
className="w-9 h-9 text-sm flex-shrink-0"
disableFavicon
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{acc.name}</p>
<p className="text-xs text-muted-foreground truncate">
{t('shared_accounts.shared_label')}
</p>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
);
})}
</div>
</SettingsSection>
)}
</div>
);
}
interface AccountRowProps {
account: AccountEntry;
index: number;
isActive: boolean;
isFirst: boolean;
isLast: boolean;
isDragOver: boolean;
onDragStart: (e: React.DragEvent, index: number) => void;
onDragOver: (e: React.DragEvent, index: number) => void;
onDrop: (e: React.DragEvent, index: number) => void;
onDragEnd: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onSwitch: () => void;
onSetDefault: () => void;
labels: {
active: string;
default: string;
setDefault: string;
switchTo: string;
moveUp: string;
moveDown: string;
dragHandle: string;
};
}
function AccountRow({
account,
index,
isActive,
isFirst,
isLast,
isDragOver,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onMoveUp,
onMoveDown,
onSwitch,
onSetDefault,
labels,
}: AccountRowProps) {
return (
<div
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
onDrop={(e) => onDrop(e, index)}
onDragEnd={onDragEnd}
className={cn(
'flex items-center gap-3 p-3 border rounded-lg transition-colors',
isDragOver
? 'border-primary bg-primary/5'
: isActive
? 'border-border bg-accent/30'
: 'border-border hover:bg-muted/50'
)}
>
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground flex-shrink-0"
title={labels.dragHandle}
>
<GripVertical className="w-4 h-4" />
</div>
<div className="relative flex-shrink-0">
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
className="w-9 h-9 text-sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && (
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-primary-foreground" />
</div>
)}
</div>
<button
type="button"
onClick={onSwitch}
disabled={isActive}
className={cn(
'min-w-0 flex-1 text-start',
!isActive && 'cursor-pointer'
)}
title={isActive ? labels.active : labels.switchTo}
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{account.displayName || account.label}
</span>
{account.isDefault && (
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" aria-label={labels.default} />
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{account.email || account.username}
</p>
<div className="flex items-center gap-1 mt-0.5">
{account.hasError ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className={cn(
'w-1.5 h-1.5 rounded-full',
account.isConnected ? 'bg-green-500' : 'bg-muted-foreground/40'
)} />
)}
<span className="text-[10px] text-muted-foreground truncate">
{hostnameOf(account.serverUrl)}
</span>
</div>
</button>
<div className="flex items-center gap-0.5 flex-shrink-0">
{!account.isDefault && (
<button
type="button"
onClick={onSetDefault}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-amber-500 transition-colors"
title={labels.setDefault}
aria-label={labels.setDefault}
>
<Star className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={labels.moveUp}
aria-label={labels.moveUp}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 10l4-4 4 4" />
</svg>
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={labels.moveDown}
aria-label={labels.moveDown}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
</div>
</div>
);
}