mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-uxs-wording' into feat/do-ux-settings-fixes
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
Reference in New Issue
Block a user