feat: list logged-in accounts, storage quota and auth method in account settings

This commit is contained in:
Linus Rath
2026-08-29 18:11:13 +02:00
parent 1f2df5f079
commit 490355c65e
3 changed files with 330 additions and 78 deletions
+55
View File
@@ -0,0 +1,55 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
// RFC 9425 Quota object (Stalwart reports storage as resourceType "octets"
// with scope "account"); older servers used a pre-RFC "mail" shape.
interface JMAPQuota {
id: string;
resourceType?: string;
used?: number;
hardLimit?: number;
warnLimit?: number;
softLimit?: number;
// pre-RFC shape
limit?: number;
scope?: string;
types?: string[];
name?: string;
}
export interface MailQuota {
used: number;
total: number;
}
export function serverSupportsQuota(): boolean {
return Boolean(jmapClient.currentSession?.capabilities?.[CAPABILITIES.QUOTA]);
}
/**
* Storage usage for the primary account, or null when the server does not
* advertise urn:ietf:params:jmap:quota or reports no mail storage quota.
* Mirrors the webmail's client.getQuota().
*/
export async function fetchMailQuota(): Promise<MailQuota | null> {
if (!serverSupportsQuota()) return null;
try {
const res = await jmapClient.request(
[['Quota/get', { accountId: jmapClient.accountId }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.QUOTA],
);
const [name, body] = res.methodResponses[0] ?? [];
if (name !== 'Quota/get') return null;
const quotas = (body?.list ?? []) as JMAPQuota[];
const coversMail = (q: JMAPQuota) =>
!q.types?.length || q.types.some((t) => t === 'Email' || t === 'Mail');
const mailQuota =
quotas.find((q) => q.resourceType === 'octets' && coversMail(q))
|| quotas.find((q) => q.resourceType === 'mail' || q.scope === 'mail');
if (!mailQuota) return null;
const total = mailQuota.hardLimit ?? mailQuota.limit ?? 0;
return { used: mailQuota.used ?? 0, total };
} catch {
return null;
}
}
+257 -78
View File
@@ -1,21 +1,19 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ArrowDown, ArrowUp, Check, Plus, Star, Trash2, Users } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
interface AccountSettingsProps {
displayName?: string;
email?: string;
username?: string;
serverUrl?: string;
authMode?: 'oauth' | 'basic';
quotaUsed?: number;
quotaTotal?: number;
isDemoMode?: boolean;
}
import { useLocaleStore } from '../../stores/locale-store';
import { jmapClient } from '../../api/jmap-client';
import { fetchMailQuota, type MailQuota } from '../../api/quota';
import { MAX_ACCOUNTS } from '../../lib/account-utils';
import type { RootStackParamList } from '../../navigation/types';
function formatFileSize(bytes: number): string {
if (!bytes) return '0 B';
@@ -29,76 +27,241 @@ function formatFileSize(bytes: number): string {
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
export function AccountSettings(props: AccountSettingsProps = {}) {
export function AccountSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const authUsername = useAuthStore((s) => s.username);
const authServerUrl = useAuthStore((s) => s.serverUrl);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const account = useAccountStore((s) =>
activeAccountId ? s.accounts.find((a) => a.id === activeAccountId) : undefined,
);
const session = useAuthStore((s) => s.session);
const switchAccount = useAuthStore((s) => s.switchAccount);
const removeAccountAuth = useAuthStore((s) => s.removeAccount);
const accounts = useAccountStore((s) => s.accounts);
const defaultAccountId = useAccountStore((s) => s.defaultAccountId);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
const account = accounts.find((a) => a.id === activeAccountId);
const displayName = props.displayName ?? account?.displayName ?? authUsername ?? 'Unknown';
const email = props.email ?? account?.email ?? authUsername ?? '';
const username = props.username ?? account?.username ?? authUsername ?? undefined;
const serverUrl = props.serverUrl ?? account?.serverUrl ?? authServerUrl ?? '';
const authMode = props.authMode ?? 'basic';
const quotaUsed = props.quotaUsed ?? 0;
const quotaTotal = props.quotaTotal ?? 0;
const isDemoMode = props.isDemoMode ?? false;
const displayName = account?.displayName || authUsername || t('settings.account.unknown', 'Unknown');
const email = account?.email || authUsername || '';
const username = account?.username || authUsername || undefined;
const serverUrl = account?.serverUrl || authServerUrl || '';
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
const authMode: 'oauth' | 'basic' = jmapClient.usesBearerAuth ? 'oauth' : 'basic';
const percent = quotaTotal ? Math.round((quotaUsed / quotaTotal) * 100) : 0;
const [quota, setQuota] = useState<MailQuota | null>(null);
useEffect(() => {
let cancelled = false;
setQuota(null);
if (!session) return;
void fetchMailQuota().then((q) => {
if (!cancelled) setQuota(q);
});
return () => { cancelled = true; };
}, [session, activeAccountId]);
const percent = quota && quota.total > 0 ? Math.min(100, Math.round((quota.used / quota.total) * 100)) : 0;
const sharedAccounts = session ? jmapClient.getSharedMailAccounts() : [];
const [busyId, setBusyId] = useState<string | null>(null);
const move = (id: string, delta: -1 | 1) => {
const ids = accounts.map((a) => a.id);
const idx = ids.indexOf(id);
const target = idx + delta;
if (idx === -1 || target < 0 || target >= ids.length) return;
[ids[idx], ids[target]] = [ids[target], ids[idx]];
reorderAccounts(ids);
};
const confirmRemove = (id: string, label: string) => {
Alert.alert(
t('sidebar.remove_account_title', 'Remove account?'),
t('sidebar.remove_account_message', 'Sign out of {account} on this device. Your mail stays on the server.', { account: label }),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('common.remove', 'Remove'),
style: 'destructive',
onPress: () => {
setBusyId(id);
void removeAccountAuth(id).finally(() => setBusyId(null));
},
},
],
);
};
return (
<SettingsSection title="Account" description="Your account information.">
<SettingItem label="Display Name">
<Text style={styles.value}>{displayName}</Text>
</SettingItem>
<SettingItem label="Email Address">
<Text style={styles.value}>{email}</Text>
</SettingItem>
{username && username !== email && (
<SettingItem label="Username">
<Text style={styles.value}>{username}</Text>
<View style={{ gap: spacing.xxxl }}>
<SettingsSection
title={t('settings.account.title', 'Account')}
description={t('settings.account.description', 'View your account information')}
>
<SettingItem label={t('settings.account.name_label', 'Display Name')}>
<Text style={styles.value}>{displayName}</Text>
</SettingItem>
)}
<SettingItem label="Authentication">
<Text style={styles.value}>
{authMode === 'oauth' ? 'OAuth' : 'Basic'}
</Text>
</SettingItem>
<SettingItem label={t('settings.account.email.label', 'Email Address')}>
<Text style={styles.value}>{email}</Text>
</SettingItem>
<SettingItem label="Server">
<Text style={styles.value} numberOfLines={1}>{serverUrl}</Text>
</SettingItem>
{username && username !== email && (
<SettingItem label={t('settings.account.username_label', 'Username')}>
<Text style={styles.value}>{username}</Text>
</SettingItem>
)}
{quotaTotal > 0 && (
<SettingItem
label="Storage"
description={`${formatFileSize(quotaUsed)} of ${formatFileSize(quotaTotal)} used`}
>
<View style={styles.storage}>
<Text style={styles.value}>{percent}%</Text>
<View style={styles.bar}>
<View style={[styles.barFill, { width: `${percent}%` }]} />
<SettingItem label={t('settings.account.auth_method_label', 'Authentication')}>
<Text style={styles.value}>
{authMode === 'oauth'
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
: t('settings.account.auth_method_basic', 'Password')}
</Text>
</SettingItem>
<SettingItem label={t('settings.account.server.label', 'JMAP Server')}>
<Text style={styles.value} numberOfLines={1}>{serverUrl}</Text>
</SettingItem>
{quota && quota.total > 0 && (
<SettingItem
label={t('settings.account.storage.label', 'Storage Usage')}
description={t('settings.account.storage.used', '{used} of {total} used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<View style={styles.storage}>
<Text style={styles.value}>
{t('settings.account.storage.percentage', '{percent}% used', { percent })}
</Text>
<View style={styles.bar} accessibilityRole="progressbar">
<View style={[styles.barFill, { width: `${percent}%` }, percent >= 90 && styles.barFillWarn]} />
</View>
</View>
</View>
</SettingItem>
)}
</SettingItem>
)}
</SettingsSection>
{isDemoMode && (
<SettingItem label="Account Type">
<View style={styles.demoRow}>
<View style={styles.demoDot} />
<Text style={styles.demoText}>Demo account</Text>
<SettingsSection
title={t('settings.account.accounts.title', 'Logged-in accounts')}
description={t('settings.account.accounts.description_mobile', 'Reorder how accounts appear in the account switcher, pick the default, or sign one out.')}
>
<View style={styles.list}>
{accounts.map((a, index) => {
const isActive = a.id === activeAccountId;
const isDefault = a.id === defaultAccountId;
const label = a.email || a.username;
return (
<View key={a.id} style={[styles.row, index > 0 && styles.rowBorder]}>
<View style={[styles.avatar, { backgroundColor: a.avatarColor }]}>
<Text style={styles.avatarText}>{(a.displayName || a.username).slice(0, 1).toUpperCase()}</Text>
</View>
<Pressable
style={{ flex: 1, minWidth: 0 }}
onPress={() => { if (!isActive) void switchAccount(a.id); }}
accessibilityRole="button"
accessibilityLabel={isActive
? t('settings.account.accounts.active', 'Currently active account')
: t('settings.account.accounts.switch_to', 'Switch to this account')}
>
<View style={styles.rowTitle}>
<Text style={styles.rowName} numberOfLines={1}>{a.displayName || a.username}</Text>
{isActive && <Check size={14} color={c.success} />}
</View>
<Text style={styles.rowSub} numberOfLines={1}>{label}</Text>
{isDefault && (
<Text style={styles.defaultBadge}>
{t('settings.account.accounts.default_badge', 'Default account')}
</Text>
)}
{a.hasError && a.errorMessage ? (
<Text style={styles.rowError} numberOfLines={1}>{a.errorMessage}</Text>
) : null}
</Pressable>
<View style={styles.rowActions}>
<Pressable
style={styles.iconBtn}
onPress={() => move(a.id, -1)}
disabled={index === 0}
accessibilityRole="button"
accessibilityLabel={t('settings.account.accounts.move_up', 'Move up')}
>
<ArrowUp size={14} color={index === 0 ? c.border : c.mutedForeground} />
</Pressable>
<Pressable
style={styles.iconBtn}
onPress={() => move(a.id, 1)}
disabled={index === accounts.length - 1}
accessibilityRole="button"
accessibilityLabel={t('settings.account.accounts.move_down', 'Move down')}
>
<ArrowDown size={14} color={index === accounts.length - 1 ? c.border : c.mutedForeground} />
</Pressable>
<Pressable
style={styles.iconBtn}
onPress={() => setDefaultAccount(a.id)}
disabled={isDefault}
accessibilityRole="button"
accessibilityLabel={t('settings.account.accounts.set_default', 'Set as default')}
>
<Star size={14} color={isDefault ? c.warning : c.mutedForeground} fill={isDefault ? c.warning : 'transparent'} />
</Pressable>
<Pressable
style={styles.iconBtn}
onPress={() => confirmRemove(a.id, label)}
disabled={busyId !== null}
accessibilityRole="button"
accessibilityLabel={t('common.remove', 'Remove')}
>
<Trash2 size={14} color={c.error} />
</Pressable>
</View>
</View>
);
})}
</View>
<View style={styles.addRow}>
<Button
variant="outline"
size="sm"
icon={<Plus size={14} color={c.text} />}
onPress={() => navigation.navigate('AddAccount')}
disabled={accounts.length >= MAX_ACCOUNTS}
>
{t('settings.account.accounts.add', 'Add account')}
</Button>
{accounts.length >= MAX_ACCOUNTS && (
<Text style={styles.hint}>
{t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS })}
</Text>
)}
</View>
</SettingsSection>
{sharedAccounts.length > 0 && (
<SettingsSection
title={t('settings.account.shared_accounts.title', 'Shared with me')}
description={t('settings.account.shared_accounts.description_mobile', 'Group and shared accounts you can read through this account. Their filters and vacation responder are managed from the webmail.')}
>
<View style={styles.list}>
{sharedAccounts.map((s, index) => (
<View key={s.id} style={[styles.row, index > 0 && styles.rowBorder]}>
<View style={[styles.avatar, { backgroundColor: c.muted }]}>
<Users size={14} color={c.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.rowName} numberOfLines={1}>{s.name}</Text>
<Text style={styles.rowSub}>{t('settings.account.shared_accounts.shared_label', 'Shared account')}</Text>
</View>
</View>
))}
</View>
</SettingItem>
</SettingsSection>
)}
</SettingsSection>
</View>
);
}
@@ -125,20 +288,36 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.primary,
borderRadius: radius.full,
},
demoRow: {
barFillWarn: { backgroundColor: c.warning },
list: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
gap: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
demoDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#f59e0b',
rowBorder: { borderTopWidth: 1, borderTopColor: c.border },
avatar: {
width: 32,
height: 32,
borderRadius: radius.full,
alignItems: 'center',
justifyContent: 'center',
},
demoText: {
...typography.bodyMedium,
color: '#fbbf24',
},
});
avatarText: { ...typography.bodyMedium, color: '#ffffff' },
rowTitle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowName: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
rowSub: { ...typography.caption, color: c.mutedForeground },
rowError: { ...typography.caption, color: c.error },
defaultBadge: { ...typography.caption, color: c.primary, marginTop: 2 },
rowActions: { flexDirection: 'row', gap: 2 },
iconBtn: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
addRow: { gap: spacing.sm, alignItems: 'flex-start' },
hint: { ...typography.caption, color: c.mutedForeground },
});
}
+18
View File
@@ -31,6 +31,9 @@ interface AccountState {
setActiveAccount: (accountId: string) => void;
setDefaultAccount: (accountId: string) => void;
updateAccount: (accountId: string, updates: Partial<AccountEntry>) => void;
// Persist a new display order (ids in the desired order; unknown ids are
// ignored, missing ones keep their relative order at the end).
reorderAccounts: (orderedIds: string[]) => void;
getAccountById: (accountId: string) => AccountEntry | undefined;
getActiveAccount: () => AccountEntry | null;
getDefaultAccount: () => AccountEntry | null;
@@ -131,6 +134,21 @@ export const useAccountStore = create<AccountState>()(
}));
},
reorderAccounts: (orderedIds) => {
set((s) => {
const byId = new Map(s.accounts.map((a) => [a.id, a]));
const next: AccountEntry[] = [];
for (const id of orderedIds) {
const entry = byId.get(id);
if (entry && !next.includes(entry)) next.push(entry);
}
for (const a of s.accounts) {
if (!next.includes(a)) next.push(a);
}
return { accounts: next };
});
},
getAccountById: (accountId) => get().accounts.find((a) => a.id === accountId),
getActiveAccount: () => {
const s = get();