Merge branch 'feat/do-uxs-wording' into feat/do-ux-settings-fixes

This commit is contained in:
Linus Rath
2026-11-04 08:35:00 +01:00
48 changed files with 2067 additions and 777 deletions
@@ -0,0 +1,152 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AccountSecuritySettings } from '../account-security-settings';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
// settings-12: "Email client setup" showed only a "JMAP username", which no
// Thunderbird or Outlook asks for. The section now lists the IMAP, SMTP,
// CalDAV and CardDAV settings the server's autoconfig names, with Copy
// buttons; the rarely needed app password fields wait behind "More options";
// the two-step switch carries no "Disabled" word beside it.
vi.mock('next-intl', () => {
const t = Object.assign(
(key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
{ raw: (key: string) => key, rich: (key: string) => key, has: () => true },
);
return { useTranslations: () => t, useLocale: () => 'en' };
});
vi.mock('@/hooks/use-config', () => ({
useConfig: () => ({ oauthEnabled: false }),
}));
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const AUTOCONFIG = `<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="example.com">
<domain>example.com</domain>
<incomingServer type="imap">
<hostname>imap.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>me@example.com</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.example.com</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<username>me@example.com</username>
</outgoingServer>
</emailProvider>
<calendar type="caldav"><serverURL>https://mail.example.com/dav/cal</serverURL></calendar>
<addressBook type="carddav"><serverURL>https://mail.example.com/dav/card</serverURL></addressBook>
</clientConfig>`;
function signIn() {
const entry = {
id: 'me@example.com@mail.example.com',
label: 'me@example.com',
serverUrl: 'https://mail.example.com',
username: 'me@example.com',
authMode: 'basic',
cookieSlot: 0,
rememberMe: false,
providerSession: false,
displayName: '',
email: 'me@example.com',
avatarColor: '#000000',
lastLoginAt: 0,
isConnected: true,
hasError: false,
isDefault: true,
} as AccountEntry;
useAccountStore.setState({ accounts: [entry], activeAccountId: entry.id });
const client = {
getUsername: () => 'me@example.com',
getServerUrl: () => 'https://mail.example.com',
getAccountId: () => 'a1',
};
useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: client as never, activeAccountId: entry.id });
}
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
useAccountSecurityStore.setState({
isStalwart: true, isProbing: false, otpEnabled: false, isSaving: false,
// Keep the store's own server probes away from the fake client.
probe: vi.fn().mockResolvedValue(undefined),
fetchAuthInfo: vi.fn().mockResolvedValue(undefined),
fetchAll: vi.fn().mockResolvedValue(undefined),
fetchPublicKeys: vi.fn().mockResolvedValue(undefined),
fetchCryptoInfo: vi.fn().mockResolvedValue(undefined),
fetchPrincipal: vi.fn().mockResolvedValue(undefined),
} as never);
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('Use your mail in other apps', () => {
it('lists the IMAP, SMTP, CalDAV and CardDAV settings the server names', async () => {
fetchMock.mockImplementation(async (url: string) => (String(url).includes('/autoconfig/') ? new Response(AUTOCONFIG, { status: 200 }) : new Response('{}', { status: 404 })));
signIn();
render(<AccountSecuritySettings />);
await waitFor(() => expect(screen.getByDisplayValue('imap.example.com')).toBeInTheDocument());
expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain(
'https://mail.example.com/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=me%40example.com',
);
expect(screen.getByDisplayValue('smtp.example.com')).toBeInTheDocument();
expect(screen.getByDisplayValue('https://mail.example.com/dav/cal')).toBeInTheDocument();
expect(screen.getByDisplayValue('https://mail.example.com/dav/card')).toBeInTheDocument();
expect(screen.getByDisplayValue('me@example.com')).toBeInTheDocument();
expect(screen.getByText(`port_security:${JSON.stringify({ port: 993, security: 'security_ssl' })}`)).toBeInTheDocument();
expect(screen.getByText(`port_security:${JSON.stringify({ port: 587, security: 'security_starttls' })}`)).toBeInTheDocument();
expect(screen.getAllByTestId('mail-apps-row')).toHaveLength(5);
expect(screen.queryByTestId('mail-apps-not-found')).toBeNull();
});
it('shows the user name and says so when the server names nothing', async () => {
fetchMock.mockResolvedValue(new Response('not here', { status: 404 }));
signIn();
render(<AccountSecuritySettings />);
await waitFor(() => expect(screen.getByTestId('mail-apps-not-found')).toBeInTheDocument());
expect(screen.getAllByTestId('mail-apps-row')).toHaveLength(1);
expect(screen.getByDisplayValue('me@example.com')).toBeInTheDocument();
});
it('keeps expiry and allowed addresses behind More options', async () => {
fetchMock.mockResolvedValue(new Response('', { status: 404 }));
signIn();
render(<AccountSecuritySettings />);
fireEvent.click(screen.getAllByText('app_passwords.new')[0]);
expect(screen.queryByText('app_passwords.expires_label')).toBeNull();
expect(screen.queryByText('app_passwords.allowed_ips_label')).toBeNull();
const more = screen.getByRole('button', { name: /more_options/ });
expect(more).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(more);
expect(screen.getByText('app_passwords.expires_label')).toBeInTheDocument();
expect(screen.getByText('app_passwords.allowed_ips_label')).toBeInTheDocument();
});
it('shows no "Disabled" word next to the two-step switch', async () => {
fetchMock.mockResolvedValue(new Response('', { status: 404 }));
signIn();
render(<AccountSecuritySettings />);
await waitFor(() => expect(screen.getByTestId('mail-apps-not-found')).toBeInTheDocument());
expect(screen.queryByText('totp.inactive')).toBeNull();
});
});
@@ -68,6 +68,8 @@ describe('CalendarManagementSettings CalDAV fallback address', () => {
render(<CalendarManagementSettings />);
// The address waits behind "Use in other apps" (usability pass 2026-11-04).
fireEvent.click(await screen.findByTestId('calendar-use-in-other-apps'));
const url = await screen.findByText('https://host.example/stalwart/.well-known/caldav');
// Left to right in Arabic too: the start of the address stays in view.
expect(url).toHaveAttribute('dir', 'ltr');
@@ -0,0 +1,133 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AddressBook, Calendar } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useContactStore } from '@/stores/contact-store';
import { useThemeStore } from '@/stores/theme-store';
import { ThemesSettings } from '../themes-settings';
import { AddressBookManagementSettings } from '../address-book-management-settings';
import { CalendarManagementSettings } from '../calendar-management-settings';
import { FilesSettingsComponent } from '../files-settings';
import { DownloadsSettings } from '../downloads-settings';
// Usability pass 2026-11-04, settings-11, -33 and -36: settings pages showed
// the server's names ("Stalwart Calendar (me@example.org)"), a raw CalDAV
// address on every calendar, English sample text in every language and a
// "Default" badge on the theme called Default. vitest.setup.ts renders
// strings as their raw keys.
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const initial = {
auth: useAuthStore.getState(),
contacts: useContactStore.getState(),
calendars: useCalendarStore.getState(),
};
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useContactStore.setState(initial.contacts, true);
useCalendarStore.setState(initial.calendars, true);
vi.restoreAllMocks();
});
describe('theme gallery', () => {
beforeEach(() => {
act(() => {
useThemeStore.setState({ activeThemeId: null, syncServerThemes: vi.fn().mockResolvedValue(undefined) });
});
});
it('puts no "Default" badge on the theme that is called Default', () => {
render(<ThemesSettings embedded />);
const card = document.querySelector('[data-search-label="default_name"]') as HTMLElement;
expect(card).toBeTruthy();
expect(within(card).queryByText('default_badge')).toBeNull();
});
});
describe('server default names', () => {
const rights = { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true };
it('shows the address book Stalwart made as "Contacts", and a chosen name as it is', () => {
useAuthStore.setState({ client: { getAccountId: () => 'acc' } } as never);
useContactStore.setState({
addressBooks: [
{ id: 'd', name: 'Stalwart Address Book (me@example.org)', isDefault: true, isShared: false, myRights: rights, shareWith: null },
{ id: 'c', name: 'Club', isDefault: false, isShared: false, myRights: rights, shareWith: null },
] as unknown as AddressBook[],
contacts: [],
supportsSync: true,
fetchAddressBooks: vi.fn(async () => {}),
} as never);
render(<AddressBookManagementSettings />);
expect(screen.getByText('default_address_book')).toBeInTheDocument();
expect(screen.getByText('Club')).toBeInTheDocument();
expect(screen.queryByText(/Stalwart Address Book/)).toBeNull();
});
it('shows the calendar Stalwart made as "Calendar" and keeps its name when saved unchanged', async () => {
const updateCalendar = vi.fn().mockResolvedValue(undefined);
useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never);
useCalendarStore.setState({
calendars: [{
id: 'cal', name: 'Stalwart Calendar (me@example.org)', color: '#3b82f6', isDefault: true, isShared: false, shareWith: null,
myRights: { mayShare: true, mayDelete: true, mayWriteAll: true },
} as unknown as Calendar],
icalSubscriptions: [],
updateCalendar,
} as never);
render(<CalendarManagementSettings />);
expect(screen.getByText('default_calendar')).toBeInTheDocument();
expect(screen.queryByText(/Stalwart Calendar/)).toBeNull();
fireEvent.click(screen.getByTitle('edit'));
fireEvent.click(screen.getByText('save'));
await vi.waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
expect(updateCalendar.mock.calls[0][2]).toEqual({ name: 'Stalwart Calendar (me@example.org)' });
});
});
describe('calendar address for other apps', () => {
it('keeps the CalDAV address behind "Use in other apps"', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 502 }));
useAuthStore.setState({
client: { getAccountId: () => 'acct-1' }, serverUrl: 'https://host.example/', username: 'alice@example.com',
} as never);
useCalendarStore.setState({
calendars: [{
id: 'cal', name: 'Home', color: '#3b82f6', isDefault: true, isShared: false, shareWith: null,
myRights: { mayShare: true, mayDelete: true, mayWriteAll: true },
} as unknown as Calendar],
icalSubscriptions: [],
updateCalendar: vi.fn(),
} as never);
render(<CalendarManagementSettings />);
const button = await screen.findByTestId('calendar-use-in-other-apps');
expect(button).toHaveTextContent('use_in_other_apps');
expect(screen.queryByText('https://host.example/.well-known/caldav')).toBeNull();
fireEvent.click(button);
expect(screen.getByText('https://host.example/.well-known/caldav')).toBeInTheDocument();
});
});
describe('previews in the user language', () => {
it('labels the Files preview through the translations', () => {
render(<FilesSettingsComponent />);
for (const key of ['name', 'size', 'modified', 'preview_documents', 'preview_photos']) {
expect(screen.getAllByText(key).length).toBeGreaterThan(0);
}
expect(screen.queryByText('Documents')).toBeNull();
expect(screen.queryByText('Modified')).toBeNull();
});
it('builds the file name preview from a translated sample subject', () => {
render(<DownloadsSettings />);
expect(document.body.textContent).toContain('sample_subject');
expect(document.body.textContent).not.toContain('Benachrichtigung');
});
});
@@ -1,12 +1,12 @@
'use client';
import { useState, useEffect, useMemo, useCallback, useId, useRef } from 'react';
import { useState, useEffect, useCallback, useId, useRef } from 'react';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, CheckCircle, Loader2, Monitor, Terminal, QrCode, Unlock, RefreshCw } from '@/components/icons';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, CheckCircle, Loader2, Terminal, ChevronDown, QrCode, Unlock, RefreshCw } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch, AdvancedOnly } from './settings-section';
@@ -25,6 +25,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { TermInfo } from '@/components/ui/term';
import { IconButton } from '@/components/ui/icon-button';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { MailAppsSetup } from './mail-apps-setup';
/** Ids that tie a visible label to its field: labelFor("key"). */
function useLabelIds() {
@@ -335,16 +336,11 @@ function TotpSection() {
return (
<div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')} term="totp">
<div className="flex items-center gap-2">
<ToggleSwitch
checked={otpEnabled || !!setupUrl}
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>
<ToggleSwitch
checked={otpEnabled || !!setupUrl}
onChange={handleToggle}
disabled={isSaving}
/>
</SettingItem>
{setupUrl && (
@@ -468,12 +464,15 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
const t = useTranslations('settings.security');
const labelFor = useLabelIds();
const tCommon = useTranslations('common');
const tWording = useTranslations('uxs_wording');
const tk = (key: string) => t(`${i18nNamespace}.${key}`);
const { isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newDescription, setNewDescription] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
// Expiry and allowed addresses are rarely needed, so they wait behind "More options".
const [showMoreOptions, setShowMoreOptions] = useState(false);
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
@@ -491,6 +490,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
setNewDescription('');
setExpiresAt('');
setAllowedIpsRaw('');
setShowMoreOptions(false);
setShowAdd(false);
toast.success(tk('added'));
} catch (err) {
@@ -582,6 +582,16 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
required
/>
</div>
<button
type="button"
aria-expanded={showMoreOptions}
onClick={() => setShowMoreOptions((open) => !open)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronDown className={cn('size-3.5 transition-transform', showMoreOptions && 'rotate-180')} aria-hidden="true" />
{tWording('more_options')}
</button>
{showMoreOptions && (<>
<div>
<label htmlFor={labelFor("expires_label")} className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
<Input id={labelFor("expires_label")} type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
@@ -598,6 +608,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
/>
<p className="text-[0.625rem] text-muted-foreground mt-1">{t('app_passwords.allowed_ips_hint')}</p>
</div>
</>)}
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
@@ -942,60 +953,6 @@ function PublicKeysSection() {
);
}
function EmailClientSection() {
const t = useTranslations('settings.security');
const labelFor = useLabelIds();
const { client } = useAuthStore();
const [copied, setCopied] = useState(false);
const jmapUsername = useMemo(() => client?.getUsername() || '', [client]);
const handleCopy = () => {
navigator.clipboard.writeText(jmapUsername).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-muted-foreground" />
<h4 data-search-label={t('email_client.title')} className="text-sm font-medium text-foreground">{t('email_client.title')}</h4>
</div>
<p className="text-xs text-muted-foreground">{t('email_client.description')}</p>
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
<div>
<div className="mb-1 flex items-center gap-1">
<label htmlFor={labelFor('jmap-username')} className="text-xs text-muted-foreground block">
{t('email_client.jmap_username_label')}
</label>
<TermInfo id="jmap" />
</div>
<div className="flex rounded-lg">
<input
id={labelFor('jmap-username')}
type="text"
readOnly
value={jmapUsername}
className="py-2 px-3 block w-full bg-background border border-border border-e-transparent rounded-s-lg text-sm text-foreground focus:z-10 focus:border-ring"
/>
<button
type="button"
onClick={handleCopy}
className="h-[38px] px-3 shrink-0 inline-flex items-center gap-1.5 rounded-e-lg border border-border bg-muted text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:outline-hidden"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? t('email_client.copied') : t('email_client.copy')}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground pt-1">{t('email_client.password_instructions')}</p>
</div>
</div>
);
}
type LinkDevicePhase = 'idle' | 'password' | 'code' | 'redeemed' | 'expired';
interface LinkDeviceMessage {
@@ -1621,7 +1578,7 @@ export function AccountSecuritySettings() {
<div className="border-t border-border" />
</>
)}
<EmailClientSection />
<MailAppsSetup />
<div className="border-t border-border" />
<AppPasswordsSection />
<AdvancedOnly>
@@ -9,6 +9,7 @@ import { useManagedAccountStore } from "@/stores/managed-account-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import { collectionDisplayName } from "@/lib/default-collection-name";
import { IconButton } from "@/components/ui/icon-button";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
@@ -79,6 +80,8 @@ export function AddressBookManagementSettings() {
const t = useTranslations("contacts.address_books");
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const tWording = useTranslations("uxs_wording");
const bookName = (book: AddressBook) => collectionDisplayName(book.name, "addressBook", tWording("default_address_book"));
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, setDefaultAddressBook, removeAddressBook, renameKeyword } = useContactStore();
@@ -144,7 +147,7 @@ export function AddressBookManagementSettings() {
if (!client) return;
const ok = await confirm({
title: t("delete_title"),
message: t("confirm_delete", { name: book.name }),
message: t("confirm_delete", { name: bookName(book) }),
confirmText: t("delete"),
variant: "destructive",
});
@@ -225,8 +228,9 @@ export function AddressBookManagementSettings() {
return (
<AddressBookEditRow
key={book.id}
initial={book.name}
onSave={(name) => handleUpdate(book, name)}
initial={bookName(book)}
// Saved unchanged, the shown name keeps the server's own.
onSave={(name) => handleUpdate(book, name === bookName(book) ? book.name : name)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
@@ -245,7 +249,7 @@ export function AddressBookManagementSettings() {
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block" title={book.name} dir="auto">{book.name}</span>
<span className="text-sm font-medium truncate block" title={bookName(book)} dir="auto">{bookName(book)}</span>
</div>
{book.isDefault && (
<span className="flex-shrink-0 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
@@ -269,7 +273,7 @@ export function AddressBookManagementSettings() {
actions sit behind a ⋮ there, as for calendars. */}
{actions.length > 0 && (
<IconButton
label={tContacts("row_actions", { name: book.name })}
label={tContacts("row_actions", { name: bookName(book) })}
className="sm:hidden flex-shrink-0 text-muted-foreground"
onClick={(e) => openContextMenuFrom(e.currentTarget, book, "end")}
>
@@ -436,7 +440,7 @@ export function AddressBookManagementSettings() {
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
collectionName={bookName(book)}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
@@ -18,6 +18,7 @@ import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts
import { ShareCollectionDialog } from './share-collection-dialog';
import type { CalendarRights } from '@/lib/jmap/types';
import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
import { collectionDisplayName } from '@/lib/default-collection-name';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
@@ -198,6 +199,7 @@ export function CalendarDefaultAlertsForm({
isLoading: boolean;
}) {
const t = useTranslations('calendar.management');
const tWording = useTranslations('uxs_wording');
// Alerts the rows can't show are written back unchanged.
const [timed] = useState(() => alertsToRows(calendar.defaultAlertsWithTime));
const [allDay] = useState(() => alertsToRows(calendar.defaultAlertsWithoutTime));
@@ -219,7 +221,7 @@ export function CalendarDefaultAlertsForm({
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<h4 className="text-sm font-medium">{t('default_alerts_title', { name: calendar.name })}</h4>
<h4 className="text-sm font-medium">{t('default_alerts_title', { name: collectionDisplayName(calendar.name, 'calendar', tWording('default_calendar')) })}</h4>
<p className="text-xs text-muted-foreground mt-0.5">{t('default_alerts_hint')}</p>
</div>
@@ -269,6 +271,8 @@ export function CalendarDefaultAlertsForm({
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const tWording = useTranslations('uxs_wording');
const calName = (cal: { name: string }) => collectionDisplayName(cal.name, 'calendar', tWording('default_calendar'));
const { client, serverUrl, username } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { calendars, updateCalendar, reorderCalendars, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
@@ -290,6 +294,8 @@ export function CalendarManagementSettings() {
const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
// The calendar whose CalDAV address is open under "Use in other apps".
const [shownUrlId, setShownUrlId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
@@ -654,8 +660,9 @@ export function CalendarManagementSettings() {
return (
<CalendarEditForm
key={cal.id}
initial={{ name: cal.name, color }}
onSave={(data) => handleUpdate(cal.id, data, color)}
initial={{ name: calName(cal), color }}
// Saved unchanged, the shown name keeps the server's own.
onSave={(data) => handleUpdate(cal.id, { ...data, name: data.name === calName(cal) ? cal.name : data.name }, color)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
@@ -735,10 +742,24 @@ export function CalendarManagementSettings() {
<CalendarIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block" title={cal.name} dir="auto">{cal.name}</span>
<span className="text-sm font-medium truncate block" title={calName(cal)} dir="auto">{calName(cal)}</span>
{(() => {
const caldavUrl = buildCalDavUrl(cal.id);
if (!caldavUrl) return null;
if (shownUrlId !== cal.id) {
return (
<button
type="button"
aria-expanded={false}
data-testid="calendar-use-in-other-apps"
onClick={(e) => { e.stopPropagation(); setShownUrlId(cal.id); }}
className="mt-0.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Link className="w-3 h-3 flex-shrink-0" aria-hidden="true" />
{tWording('use_in_other_apps')}
</button>
);
}
return (
<div className="flex items-center gap-1 mt-0.5">
<Link className="w-3 h-3 text-muted-foreground flex-shrink-0" />
@@ -791,7 +812,7 @@ export function CalendarManagementSettings() {
and pushed the Default badge over the copy button, so the
row actions sit behind a ⋮ there, as for folders. */}
<IconButton
label={t('row_actions', { name: cal.name })}
label={t('row_actions', { name: calName(cal) })}
className="sm:hidden flex-shrink-0 text-muted-foreground"
onClick={(e) => openContextMenuFrom(e.currentTarget, cal, 'end')}
>
@@ -943,7 +964,7 @@ export function CalendarManagementSettings() {
onClose={() => setDeletingId(null)}
onConfirm={() => { if (deletingId) void handleDelete(deletingId); }}
title={tConfirm('delete_title')}
message={t('confirm_delete', { name: calendars.find((cal) => cal.id === deletingId)?.name ?? '' })}
message={t('confirm_delete', { name: calName(calendars.find((cal) => cal.id === deletingId) ?? { name: '' }) })}
confirmText={t('delete')}
variant="destructive"
/>
@@ -952,7 +973,7 @@ export function CalendarManagementSettings() {
onClose={() => setClearingId(null)}
onConfirm={() => { if (clearingId) void handleClear(clearingId); }}
title={tConfirm('clear_title')}
message={t('confirm_clear', { name: calendars.find((cal) => cal.id === clearingId)?.name ?? '' })}
message={t('confirm_clear', { name: calName(calendars.find((cal) => cal.id === clearingId) ?? { name: '' }) })}
confirmText={t('clear_events')}
variant="destructive"
/>
@@ -996,7 +1017,7 @@ export function CalendarManagementSettings() {
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
collectionName={calName(cal)}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
+4 -1
View File
@@ -147,7 +147,10 @@ export function DownloadsSettings() {
// With the Archive action off a stored "archive" keeps the message, so it shows as "keep".
const postExportShown = archiveActionEnabled || postExportAction !== "archive" ? postExportAction : "keep";
const sampleEmail = useMemo(() => buildSampleEmail(), []);
const tWording = useTranslations("uxs_wording");
// A subject in the user's language, with a letter or two the "plain letters" option changes.
const sampleSubject = tWording("sample_subject");
const sampleEmail = useMemo(() => buildSampleEmail(sampleSubject), [sampleSubject]);
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
const transform = useMemo(
+14 -8
View File
@@ -11,6 +11,8 @@ import { useConfig } from "@/hooks/use-config";
interface SampleFile {
name: string;
/** Folder names are translated; file names stay as they are. */
labelKey?: "preview_documents" | "preview_photos";
isFolder: boolean;
size: number;
modified: string;
@@ -19,11 +21,11 @@ interface SampleFile {
}
const SAMPLE_FILES: SampleFile[] = [
{ name: "Documents", isFolder: true, size: 0, modified: "2026-03-10" },
{ name: "Photos", isFolder: true, size: 0, modified: "2026-03-14" },
{ name: "Documents", labelKey: "preview_documents", isFolder: true, size: 0, modified: "2026-03-10" },
{ name: "Photos", labelKey: "preview_photos", isFolder: true, size: 0, modified: "2026-03-14" },
{ name: "report.pdf", isFolder: false, size: 245000, modified: "2026-03-15" },
{ name: "notes.md", isFolder: false, size: 1200, modified: "2026-03-12" },
{ name: "vacation.jpg", isFolder: false, size: 3400000, modified: "2026-03-08", thumbnailUrl: "/branding/Bulwark_Logo_Color.png" },
{ name: "logo.png", isFolder: false, size: 34000, modified: "2026-03-08", thumbnailUrl: "/branding/Bulwark_Logo_Color.png" },
{ name: "song.mp3", isFolder: false, size: 5200000, modified: "2026-03-01" },
{ name: ".config", isFolder: false, size: 340, modified: "2026-02-20", hidden: true },
];
@@ -58,12 +60,16 @@ function formatSize(bytes: number): string {
}
function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
const tFiles = useTranslations("files");
const tWording = useTranslations("uxs_wording");
// The sample thumbnail shows the configured logo on a branded deployment,
// the bundled one otherwise. (#1160)
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const thumbnailUrl = appLogoLightUrl || appLogoDarkUrl || "/branding/Bulwark_Logo_Color.png";
const sortedFiles = useMemo(() => {
let files = SAMPLE_FILES.map((f) => (f.thumbnailUrl ? { ...f, thumbnailUrl } : f)).filter((f) => {
let files = SAMPLE_FILES
.map((f) => (f.labelKey ? { ...f, name: tWording(f.labelKey) } : f))
.map((f) => (f.thumbnailUrl ? { ...f, thumbnailUrl } : f)).filter((f) => {
if (!settings.showHiddenFiles && f.hidden) return false;
return true;
});
@@ -82,14 +88,14 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
});
return files;
}, [settings.showHiddenFiles, settings.defaultSortKey, settings.defaultSortDir, thumbnailUrl]);
}, [settings.showHiddenFiles, settings.defaultSortKey, settings.defaultSortDir, thumbnailUrl, tWording]);
const listView = (
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-3 px-2 py-1 text-[0.625rem] font-medium text-muted-foreground border-b border-border bg-muted/50">
<span className="flex-1 min-w-0">Name</span>
<span className="w-14 text-end">Size</span>
<span className="w-16 text-end">Modified</span>
<span className="flex-1 min-w-0">{tFiles("name")}</span>
<span className="w-14 text-end">{tFiles("size")}</span>
<span className="w-16 text-end">{tFiles("modified")}</span>
</div>
{sortedFiles.map((file) => (
<div
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Check, Copy, Loader2, Monitor } from '@/components/icons';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
import { fetchMailClientConfig, type MailClientConfig, type MailSocketSecurity } from '@/lib/mail-client-config';
/**
* Settings > Security > "Use your mail in other apps": the user name, the
* IMAP and SMTP servers with port and encryption, and the CalDAV and CardDAV
* addresses, each with a Copy button. The values come from the mail server's
* autoconfig document; when it has none, only the user name shows, with a
* line saying so.
*/
function CopyRow({ label, value, detail }: { label: string; value: string; detail?: string }) {
const t = useTranslations('uxs_wording.mail_apps');
const tSecurity = useTranslations('settings.security');
const id = useId();
const [copied, setCopied] = useState(false);
const copy = () => {
void navigator.clipboard?.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div data-testid="mail-apps-row">
<label htmlFor={id} className="mb-1 block text-xs text-muted-foreground">{label}</label>
<div className="flex rounded-lg">
<input
id={id}
type="text"
readOnly
dir="ltr"
value={value}
className="py-2 px-3 block w-full min-w-0 bg-background border border-border border-e-transparent rounded-s-lg text-sm text-foreground focus:z-10 focus:border-ring"
/>
<button
type="button"
onClick={copy}
aria-label={t('copy_label', { name: label })}
className="h-[38px] px-3 shrink-0 inline-flex items-center gap-1.5 rounded-e-lg border border-border bg-muted text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:outline-hidden"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span aria-hidden="true">{copied ? tSecurity('email_client.copied') : tSecurity('email_client.copy')}</span>
</button>
</div>
{detail && <p className="mt-1 text-xs text-muted-foreground">{detail}</p>}
</div>
);
}
export function MailAppsSetup() {
const t = useTranslations('uxs_wording.mail_apps');
const tSecurity = useTranslations('settings.security');
const client = useAuthStore((s) => s.client);
const identities = useIdentityStore((s) => s.identities);
const username = useMemo(() => client?.getUsername() || '', [client]);
const serverUrl = useMemo(() => client?.getServerUrl() || '', [client]);
// The autoconfig asks for an address; a bare login name takes the first identity's.
const emailAddress = username.includes('@') ? username : (identities.find((i) => i.email)?.email ?? '');
const [config, setConfig] = useState<MailClientConfig | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!serverUrl || !emailAddress) {
setConfig(null);
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
void fetchMailClientConfig(serverUrl, emailAddress, fetch, controller.signal).then((result) => {
if (controller.signal.aborted) return;
setConfig(result);
setLoading(false);
});
return () => controller.abort();
}, [serverUrl, emailAddress]);
const securityLabel = (security: MailSocketSecurity) => t(`security_${security}`);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-muted-foreground" />
<h4 data-search-label={tSecurity('email_client.title')} className="text-sm font-medium text-foreground">
{tSecurity('email_client.title')}
</h4>
</div>
<p className="text-xs text-muted-foreground">{tSecurity('email_client.description')}</p>
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-3">
<CopyRow label={tSecurity('email_client.jmap_username_label')} value={config?.username || username} />
{config?.imap && (
<CopyRow
label={t('imap')}
value={config.imap.hostname}
detail={t('port_security', { port: config.imap.port, security: securityLabel(config.imap.security) })}
/>
)}
{config?.smtp && (
<CopyRow
label={t('smtp')}
value={config.smtp.hostname}
detail={t('port_security', { port: config.smtp.port, security: securityLabel(config.smtp.security) })}
/>
)}
{config?.caldav && <CopyRow label={t('caldav')} value={config.caldav} />}
{config?.carddav && <CopyRow label={t('carddav')} value={config.carddav} />}
{loading ? (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" role="status">
<Loader2 className="size-3.5 animate-spin" />
{t('loading')}
</p>
) : !config ? (
<p className="text-xs text-muted-foreground" data-testid="mail-apps-not-found">{t('not_found')}</p>
) : null}
<p className="text-xs text-muted-foreground pt-1">{tSecurity('email_client.password_instructions')}</p>
</div>
</div>
);
}
+2 -1
View File
@@ -237,7 +237,8 @@ function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActiv
<Lock className="w-2.5 h-2.5" />
</span>
)}
{isDefault && (
{/* The built-in theme is called Default already: a badge saying so again is noise. */}
{isDefault && !isDefaultTheme && (
<span className="text-[0.625rem] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">{t('default_badge')}</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}