mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: explain technical terms next to their labels in Settings and the admin area
This commit is contained in:
@@ -262,7 +262,7 @@ export function AuthPage() {
|
||||
<div className="space-y-10">
|
||||
<SectionNav />
|
||||
|
||||
<SettingsSection id="oauth" title="OAuth / OpenID Connect">
|
||||
<SettingsSection id="oauth" title="OAuth / OpenID Connect" term="oauth">
|
||||
<SettingItem
|
||||
label="Set up with Stalwart"
|
||||
description="Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. Requires your Stalwart account to have admin permissions."
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveDefaultRelayUrl,
|
||||
} from '@/lib/push-relays';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, SectionNav } from '@/components/settings/settings-section';
|
||||
import type { GlossaryId } from '@/components/ui/term';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
@@ -29,7 +30,7 @@ import { inputClass } from '@/components/admin/config-items';
|
||||
// forward on policy load), so it is hidden from the admin UI.
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
|
||||
|
||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string; term?: GlossaryId }>> = {
|
||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||
@@ -37,13 +38,13 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
|
||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing', term: 'smime' },
|
||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.', term: 'webdav' },
|
||||
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
@@ -290,9 +291,9 @@ export function PolicyPage() {
|
||||
.map(key => {
|
||||
const meta = FEATURE_GATE_LABELS[key];
|
||||
if (!meta) return null;
|
||||
const { label, description } = meta;
|
||||
const { label, description, term } = meta;
|
||||
return (
|
||||
<SettingItem key={key} label={label} description={description}>
|
||||
<SettingItem key={key} label={label} description={description} term={term}>
|
||||
<ToggleSwitch checked={!!policy.features[key]} onChange={() => toggleFeature(key)} />
|
||||
</SettingItem>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { withEdit } from '@/lib/admin/edits';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, ConfigNote } from '@/components/admin/config-items';
|
||||
import { JmapServersSection } from './_jmap-servers-section';
|
||||
import { Term } from '@/components/ui/term';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface ConfigEntry {
|
||||
@@ -118,11 +119,11 @@ export function SettingsPage() {
|
||||
|
||||
<SettingsSection id="general" title="General">
|
||||
<ConfigTextItem label="Application name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigTextItem label="JMAP server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" wide />
|
||||
<ConfigTextItem label="JMAP server URL" term="jmap" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" wide />
|
||||
<ConfigToggleItem label="Allow custom JMAP endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||
<ConfigNote tone="warning">
|
||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||
<strong><Term id="cors">CORS</Term> warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||
</ConfigNote>
|
||||
)}
|
||||
<ConfigToggleItem label="Stalwart features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
@@ -132,8 +133,8 @@ export function SettingsPage() {
|
||||
|
||||
<SettingsSection id="login" title="Login page">
|
||||
<ConfigToggleItem label="Show version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show 2FA code option" description="Show the "I have a 2FA code" toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show access token sign-in" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show 2FA code option" term="two_factor" description="Show the "I have a 2FA code" toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show access token sign-in" term="access_token" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show remember me" description="Let people choose whether to stay signed in. Off (the default) remembers every sign-in. Either way this needs SESSION_SECRET to be set." configKey="loginShowRememberMe" value={currentValue('loginShowRememberMe') as boolean} source={config.loginShowRememberMe?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show heading" description="Show the application name below the login logo" configKey="loginShowHeading" value={currentValue('loginShowHeading') as boolean} source={config.loginShowHeading?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show subtitle" description="Show the subtitle below the login heading" configKey="loginShowSubtitle" value={currentValue('loginShowSubtitle') as boolean} source={config.loginShowSubtitle?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import { RotateCcw } from '@/components/icons';
|
||||
import { SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import type { GlossaryId } from '@/components/ui/term';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from './badge';
|
||||
|
||||
@@ -52,6 +53,8 @@ interface ConfigItemBase {
|
||||
/** Rows that only matter in unusual setups, hidden behind the Advanced switch. */
|
||||
advanced?: boolean;
|
||||
disabled?: boolean;
|
||||
/** A technical term in the label that gets an info button with its explanation. */
|
||||
term?: GlossaryId;
|
||||
}
|
||||
|
||||
interface ConfigTextItemProps extends ConfigItemBase {
|
||||
@@ -63,10 +66,10 @@ interface ConfigTextItemProps extends ConfigItemBase {
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export function ConfigTextItem({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text', mono, wide, advanced, disabled }: ConfigTextItemProps) {
|
||||
export function ConfigTextItem({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text', mono, wide, advanced, disabled, term }: ConfigTextItemProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<SettingItem label={label} description={description} advanced={advanced}>
|
||||
<SettingItem label={label} description={description} advanced={advanced} term={term}>
|
||||
<div className="flex w-full items-center gap-1.5 sm:w-auto">
|
||||
<SourceBadge source={source} />
|
||||
<input
|
||||
@@ -91,9 +94,9 @@ interface ConfigToggleItemProps extends ConfigItemBase {
|
||||
value: boolean | undefined | null;
|
||||
}
|
||||
|
||||
export function ConfigToggleItem({ label, description, configKey, value, source, onChange, onRevert, advanced, disabled }: ConfigToggleItemProps) {
|
||||
export function ConfigToggleItem({ label, description, configKey, value, source, onChange, onRevert, advanced, disabled, term }: ConfigToggleItemProps) {
|
||||
return (
|
||||
<SettingItem label={label} description={description} advanced={advanced}>
|
||||
<SettingItem label={label} description={description} advanced={advanced} term={term}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SourceBadge source={source} />
|
||||
<ToggleSwitch checked={!!value} disabled={disabled} onChange={(checked) => onChange(configKey, checked)} />
|
||||
@@ -108,10 +111,10 @@ interface ConfigSelectItemProps extends ConfigItemBase {
|
||||
options: ReadonlyArray<string | { value: string; label: string }>;
|
||||
}
|
||||
|
||||
export function ConfigSelectItem({ label, description, configKey, value, source, options, onChange, onRevert, advanced, disabled }: ConfigSelectItemProps) {
|
||||
export function ConfigSelectItem({ label, description, configKey, value, source, options, onChange, onRevert, advanced, disabled, term }: ConfigSelectItemProps) {
|
||||
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
||||
return (
|
||||
<SettingItem label={label} description={description} advanced={advanced}>
|
||||
<SettingItem label={label} description={description} advanced={advanced} term={term}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SourceBadge source={source} />
|
||||
<select
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useConfig } from '@/hooks/use-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { InheritNote, RestrictionRow } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
import { Term } from '@/components/ui/term';
|
||||
|
||||
const textareaClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground ' +
|
||||
@@ -302,7 +303,7 @@ export function SignatureSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
/>
|
||||
{sampleDisclaimer && <TemplatePreview html={sampleDisclaimer.html} text={sampleDisclaimer.text} testId="org-disclaimer-sample" />}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bulwark adds the signature and the disclaimer in the browser. Mail sent from other apps over IMAP and SMTP gets neither.
|
||||
Bulwark adds the signature and the disclaimer in the browser. Mail sent from other apps over <Term id="imap">IMAP</Term> and <Term id="smtp">SMTP</Term> gets neither.
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Calendar as CalendarIcon, CheckSquare, Layers } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TermInfo } from "@/components/ui/term";
|
||||
import type { CalendarComponentType } from "@/lib/jmap/types";
|
||||
|
||||
/**
|
||||
@@ -66,7 +67,7 @@ export function CalendarKindPicker({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("kind_hint")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("kind_hint")} <TermInfo id="caldav" /></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ describe('Email addresses section', () => {
|
||||
render(<EmailAddressesSection />);
|
||||
const row = screen.getByText('sub_addressing.label').closest('[data-search-label]') as HTMLElement;
|
||||
expect(within(row).getByText('sub_addressing.description')).toBeTruthy();
|
||||
expect(within(row).queryByRole('button')).toBeNull();
|
||||
// Only the glossary info button that explains the term.
|
||||
expect(within(row).queryAllByRole('button').map((b) => b.getAttribute('data-glossary-term'))).toEqual(['sub_addressing']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'manage' }));
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
|
||||
@@ -16,6 +16,29 @@ describe('SettingItem controls', () => {
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('puts a glossary info button beside the label without changing the toggle name', () => {
|
||||
render(
|
||||
<SettingItem label="App passwords" term="app_password">
|
||||
<ToggleSwitch checked onChange={() => {}} />
|
||||
</SettingItem>
|
||||
);
|
||||
|
||||
// The mocked translator returns keys: the button is named by glossary.what_is.
|
||||
const info = screen.getByRole('button', { name: 'what_is' });
|
||||
expect(info).toHaveAttribute('data-glossary-term', 'app_password');
|
||||
expect(info.closest('label')).toBeNull();
|
||||
expect(screen.getByRole('switch', { name: 'App passwords' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a glossary info button to a section title', () => {
|
||||
render(
|
||||
<SettingsSection title="Delegates" term="delegation">
|
||||
<p>rows</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: /Delegates/ }).querySelector('[data-glossary-term="delegation"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lets an explicit ariaLabel win over the row label', () => {
|
||||
render(
|
||||
<SettingItem label="Conversation view">
|
||||
|
||||
@@ -183,7 +183,7 @@ export function AboutDataSettings() {
|
||||
<div className="space-y-8">
|
||||
<SettingsSection id="backup" title={t('sections.backup')} description={t('sections.backup_description')}>
|
||||
{settingsSyncEnabled && (
|
||||
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
||||
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')} term="sync">
|
||||
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
@@ -215,7 +215,7 @@ export function AboutDataSettings() {
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="fix" title={t('sections.fix')} description={t('sections.fix_description')}>
|
||||
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')}>
|
||||
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')} term="cache">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRefreshConfirm(true)}>
|
||||
{t('refresh_cache.button')}
|
||||
</Button>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isPasswordSession } from '@/lib/auth/password-session';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { totpIssuer } from '@/lib/totp-issuer';
|
||||
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';
|
||||
|
||||
@@ -332,7 +333,7 @@ function TotpSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SettingItem label={t('totp.label')} description={t('totp.description')}>
|
||||
<SettingItem label={t('totp.label')} description={t('totp.description')} term="totp">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleSwitch
|
||||
checked={otpEnabled || !!setupUrl}
|
||||
@@ -542,6 +543,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 data-search-label={tk('title')} className="text-sm font-medium text-foreground">{tk('title')}</h4>
|
||||
{i18nNamespace === 'app_passwords' && <TermInfo id="app_password" />}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
@@ -773,6 +775,7 @@ function PublicKeysSection() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 data-search-label={t('encryption.section_title')} className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
|
||||
<TermInfo id="encryption_at_rest" />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
@@ -794,7 +797,10 @@ function PublicKeysSection() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={labelFor("field10")} className="text-xs text-muted-foreground mb-1 block">{tk('key_label')}</label>
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<label htmlFor={labelFor("field10")} className="text-xs text-muted-foreground block">{tk('key_label')}</label>
|
||||
<TermInfo id="public_key" />
|
||||
</div>
|
||||
<textarea
|
||||
id={labelFor("field10")}
|
||||
value={publicKey}
|
||||
@@ -959,9 +965,12 @@ function EmailClientSection() {
|
||||
<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>
|
||||
<label htmlFor={labelFor('jmap-username')} className="text-xs text-muted-foreground mb-1 block">
|
||||
{t('email_client.jmap_username_label')}
|
||||
</label>
|
||||
<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')}
|
||||
@@ -1585,6 +1594,7 @@ export function AccountSecuritySettings() {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 data-search-label={t('totp.section_title')} className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
|
||||
<TermInfo id="two_factor" />
|
||||
</div>
|
||||
<TotpSection />
|
||||
</div>
|
||||
|
||||
@@ -178,7 +178,7 @@ export function AccountSettings() {
|
||||
</SettingItem>
|
||||
|
||||
{/* Server */}
|
||||
<SettingItem label={t('server.label')} advanced>
|
||||
<SettingItem label={t('server.label')} advanced term="jmap">
|
||||
<span className="text-sm text-foreground truncate max-w-xs">
|
||||
{serverUrl || tCommon('unknown')}
|
||||
</span>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { shareRecipientNames, useShareRecipientLabels } from '@/hooks/use-share-
|
||||
import { calendarAccountKey, moveCalendar } from '@/lib/calendar-sort';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { TOUCH_HIT_AREA } from '@/components/ui/button';
|
||||
import { TermInfo } from '@/components/ui/term';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
|
||||
@@ -866,6 +867,7 @@ export function CalendarManagementSettings() {
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
{tSub('section_title')}
|
||||
<TermInfo id="icalendar" />
|
||||
</h4>
|
||||
{icalSubscriptions.map((sub) => {
|
||||
return (
|
||||
|
||||
@@ -83,6 +83,7 @@ export function ContactsSettings() {
|
||||
<SettingItem
|
||||
label={tSettings("import_label")}
|
||||
description={tSettings("import_description")}
|
||||
term="vcard"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowImport(true)}>
|
||||
<Upload className="w-4 h-4 me-2" />
|
||||
|
||||
@@ -155,7 +155,7 @@ export function DelegatesSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="delegates" title={t("title")} description={t("description")}>
|
||||
<SettingsSection id="delegates" title={t("title")} description={t("description")} term="delegation">
|
||||
<div>
|
||||
<Button onClick={() => setDialog({ mode: "add" })} disabled={!targets} data-testid="delegates-give-access">
|
||||
<UserPlus className="me-2 h-4 w-4" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SettingsSection, ToggleSwitch } from "./settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TermInfo } from "@/components/ui/term";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -757,6 +758,7 @@ export function FilterSettings() {
|
||||
<Code className="w-4 h-4 me-1" />
|
||||
{t("raw_editor")}
|
||||
</Button>
|
||||
<TermInfo id="sieve" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -38,6 +38,7 @@ export function EmailAddressesSection() {
|
||||
<SettingItem
|
||||
label={t('identities_count.label')}
|
||||
description={t('identities_count.description')}
|
||||
term="identity"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">
|
||||
@@ -58,6 +59,7 @@ export function EmailAddressesSection() {
|
||||
<SettingItem
|
||||
label={t('sub_addressing.label')}
|
||||
description={t('sub_addressing.description')}
|
||||
term="sub_addressing"
|
||||
>
|
||||
{null}
|
||||
</SettingItem>
|
||||
|
||||
@@ -417,7 +417,7 @@ export function NotificationSettings() {
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="push" title={t('push.title')} description={t('push.description')}>
|
||||
<SettingsSection id="push" title={t('push.title')} description={t('push.description')} term="push">
|
||||
<SettingItem label={t('push.enable')} description={statusDescription}>
|
||||
<div className="flex items-center gap-2">
|
||||
{pushEnabled && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { OrgLockedNote } from './org-locked-note';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, TOUCH_HIT_AREA } from '@/components/ui/button';
|
||||
import { TermInfo, type GlossaryId } from '@/components/ui/term';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
/**
|
||||
@@ -189,9 +190,11 @@ interface SettingsSectionProps {
|
||||
advanced?: boolean;
|
||||
/** Anchor for section deep links and the in-page jump row. */
|
||||
id?: string;
|
||||
/** A technical term in the title that gets an info button with its explanation. */
|
||||
term?: GlossaryId;
|
||||
}
|
||||
|
||||
export function SettingsSection({ title, description, children, advanced, id }: SettingsSectionProps) {
|
||||
export function SettingsSection({ title, description, children, advanced, id, term }: SettingsSectionProps) {
|
||||
const visible = useAdvancedVisible(advanced);
|
||||
if (!visible) return null;
|
||||
return (
|
||||
@@ -199,6 +202,7 @@ export function SettingsSection({ title, description, children, advanced, id }:
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground flex items-center gap-2">
|
||||
{title}
|
||||
{term && <TermInfo id={term} />}
|
||||
{advanced && <AdvancedBadge />}
|
||||
</h3>
|
||||
{description && (
|
||||
@@ -219,6 +223,12 @@ interface SettingItemProps {
|
||||
advanced?: boolean;
|
||||
/** Indented under the row above it, for a sub-option of a toggle. */
|
||||
nested?: boolean;
|
||||
/**
|
||||
* A technical term in the label that gets an info button with its
|
||||
* explanation. The button sits beside the label, not inside it, so the
|
||||
* row's control keeps the label alone as its name.
|
||||
*/
|
||||
term?: GlossaryId;
|
||||
}
|
||||
|
||||
/** A field that has a name of its own: aria-label, aria-labelledby or a <label>. */
|
||||
@@ -228,7 +238,7 @@ function hasOwnName(el: HTMLElement): boolean {
|
||||
return !!el.id && !!el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
||||
}
|
||||
|
||||
export function SettingItem({ label, description, children, locked, advanced, nested }: SettingItemProps) {
|
||||
export function SettingItem({ label, description, children, locked, advanced, nested, term }: SettingItemProps) {
|
||||
const labelId = useId();
|
||||
const visible = useAdvancedVisible(advanced);
|
||||
// A row with one text field, select or textarea and nothing naming it
|
||||
@@ -256,6 +266,7 @@ export function SettingItem({ label, description, children, locked, advanced, ne
|
||||
<div className="flex-1 min-w-0 sm:pe-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label id={labelId} className="text-sm font-medium text-foreground">{label}</label>
|
||||
{term && <TermInfo id={term} />}
|
||||
{advanced && <AdvancedBadge />}
|
||||
</div>
|
||||
{description && (
|
||||
|
||||
Reference in New Issue
Block a user