mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
feat(settings): one look for every user and admin settings page
The settings pages had grown into a mix of styles: folds, dialogs behind rows, full-width rows next to boxed ones, pills for every figure and buttons in four sizes. Every page now uses the same kit. - Cards keep their tinted head band; every part of a card is a rounded block with its own small head (SettingsBlock), and rows sit in rounded boxes. - Nothing folds and nothing hides behind a dialog: the MCP config, OAuth scopes, mail how-to, AI privacy note and addon sub-options are all shown. - Each page is split into two columns balanced by length (SettingsColumns). - Long explanations moved into an info mark next to the label (HintMark). - One button size everywhere (text-control); tab names are one word. - Admin: the old Settings tab is split into Security and Services (?tab=settings still opens Security, and the phone keeps its single Settings section for both); the Integration group is gone, live flights and mail import sit next to the addons they configure. - Addons: one card per type with rows, no search, filter or fold. - Quota, TREK API facts, mail import status and Google's daily limit are compact lines and strips instead of tiles and pills. - Language is a select; routing has its own card under the defaults; storage saves from the foot of its categories card. - Unused keys, FoldSummary and the page-width wrapper are removed.
This commit is contained in:
@@ -53,12 +53,6 @@ function isOn(toggle: HTMLElement): boolean {
|
||||
return toggle.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
|
||||
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
|
||||
async function unfoldShelves(): Promise<void> {
|
||||
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
for (const bar of bars) await userEvent.click(bar);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -236,7 +230,6 @@ describe('AddonManager', () => {
|
||||
)
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await unfoldShelves();
|
||||
|
||||
// Provider sub-rows are visible under Journey addon
|
||||
await screen.findByText('Unsplash');
|
||||
@@ -272,15 +265,6 @@ describe('AddonManager', () => {
|
||||
expect(screen.getByText('No addons available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
|
||||
// The wordmark reads the .dark class, the same source applyAppearance() writes.
|
||||
document.documentElement.classList.add('dark');
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('No addons available');
|
||||
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
|
||||
@@ -302,7 +286,6 @@ describe('AddonManager', () => {
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Immich');
|
||||
// immich and synologyphotos ship a vendor glyph, unsplash does not
|
||||
@@ -437,7 +420,6 @@ describe('AddonManager', () => {
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Paperless-ngx');
|
||||
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
|
||||
@@ -536,7 +518,6 @@ describe('AddonManager', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Chat');
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||
@@ -567,7 +548,6 @@ describe('AddonManager', () => {
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await unfoldShelves();
|
||||
|
||||
const links = await screen.findByText('Links');
|
||||
expect(links).toHaveAttribute('title');
|
||||
@@ -623,7 +603,7 @@ describe('AddonManager', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
||||
it('FE-ADMIN-ADDON-040: the options of an addon are listed under it at once, nothing to unfold', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
<AddonManager
|
||||
@@ -632,16 +612,9 @@ describe('AddonManager', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
|
||||
expect(bar).toHaveTextContent('2 on');
|
||||
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(bar);
|
||||
expect(bar).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('Chat')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(bar);
|
||||
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Chat' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Polls' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
|
||||
@@ -668,10 +641,6 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
// Folded like Collab's and Documents' options until the bar is opened.
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
await unfoldShelves();
|
||||
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
||||
expect(isOn(toggle)).toBe(false);
|
||||
await userEvent.click(toggle);
|
||||
@@ -685,8 +654,7 @@ describe('AddonManager', () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
await unfoldShelves();
|
||||
await screen.findByText('Tours');
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -702,7 +670,6 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
await unfoldShelves();
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
||||
|
||||
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
||||
@@ -723,33 +690,4 @@ describe('AddonManager', () => {
|
||||
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
||||
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
|
||||
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
|
||||
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
|
||||
expect(screen.getByText('Budget')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Todo List')).toBeNull();
|
||||
expect(screen.queryByText('Vacay')).toBeNull();
|
||||
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
|
||||
await user.click(screen.getByRole('button', { name: 'On' }));
|
||||
expect(screen.getByText('Todo List')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Budget')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Off' }));
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
|
||||
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, type ComponentType } from 'react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
@@ -9,8 +8,7 @@ import { DOCUMENT_PROVIDER_ICONS } from '../shared/documentProviderIconMap'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
import { SettingsCard, SettingsColumns, StatusPill } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import AiAddonTile from './addons/AiAddonTile'
|
||||
@@ -67,9 +65,15 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature, aside }: {
|
||||
bagTrackingEnabled?: boolean
|
||||
onToggleBagTracking?: () => void
|
||||
collabFeatures?: CollabFeatures
|
||||
onToggleCollabFeature?: (key: string) => void
|
||||
/** Cards that set up what the integrations reach (flight status, booking mails), under them in the right column. */
|
||||
aside?: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
@@ -77,9 +81,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
const [query, setQuery] = useState('')
|
||||
const [view, setView] = useState<AddonView>('all')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SettingsCard icon={Puzzle} title={t('admin.addons.title')}>
|
||||
@@ -210,32 +211,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{ key: 'global', addons: globalAddons },
|
||||
{ key: 'integration', addons: integrationAddons },
|
||||
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
|
||||
|
||||
const subtitle = (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</span>
|
||||
)
|
||||
|
||||
/** Whether an addon is shown under the search and the filter. */
|
||||
const shown = (addon: Addon) => {
|
||||
if (view === 'on' && !addon.enabled) return false
|
||||
if (view === 'off' && addon.enabled) return false
|
||||
const q = query.trim().toLocaleLowerCase()
|
||||
if (!q) return true
|
||||
const label = getAddonLabel(t, addon)
|
||||
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
|
||||
}
|
||||
|
||||
const card = (g: (typeof groups)[number]) => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
|
||||
const visible = g.addons.filter(shown)
|
||||
const ai = g.key === 'integration' && aiAddons.some(shown)
|
||||
const visible = g.addons
|
||||
const ai = g.key === 'integration' && aiAddons.length > 0
|
||||
if (!visible.length && !ai) return null
|
||||
const on = all.filter(a => a.enabled).length
|
||||
return (
|
||||
@@ -249,8 +230,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
|
||||
</Tooltip>
|
||||
)}
|
||||
bodyClassName="p-3"
|
||||
>
|
||||
{/* The addons in one rounded block of rows, as every settings card holds its rows. */}
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
|
||||
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
@@ -263,103 +244,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const [first, ...rest] = groups
|
||||
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0
|
||||
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
|
||||
if (addons.length === 0) {
|
||||
return (
|
||||
<SettingsCard icon={Puzzle} title={t('admin.addons.title')}>
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
|
||||
<div className="min-w-0">{cards.start}</div>
|
||||
<div className="min-w-0">{cards.end}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
|
||||
{t('admin.addons.noMatch')}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AddonView = 'all' | 'on' | 'off'
|
||||
|
||||
/**
|
||||
* The head card's body: a tile per type with how many of it are on, then a
|
||||
* search over the addons' names and descriptions and a switch between all,
|
||||
* the ones on and the ones off.
|
||||
*/
|
||||
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
|
||||
groups: { key: string; addons: Addon[] }[]
|
||||
query: string
|
||||
onQuery: (q: string) => void
|
||||
view: AddonView
|
||||
onView: (v: AddonView) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const Icon = meta.icon
|
||||
const on = g.addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
|
||||
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
|
||||
</span>
|
||||
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<label className="relative flex min-w-0 flex-1 items-center">
|
||||
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => onQuery(e.target.value)}
|
||||
placeholder={t('admin.addons.search')}
|
||||
aria-label={t('admin.addons.search')}
|
||||
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
|
||||
style={fs(13, 'body')}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
|
||||
{(['all', 'on', 'off'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onView(v)}
|
||||
aria-pressed={view === v}
|
||||
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(`admin.addons.filter.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return <SettingsColumns left={cards.start} right={<>{cards.end}{aside}</>} />
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ export default function AddonSubRow({
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className="flex min-h-[44px] items-center gap-2.5 px-3 py-1.5">
|
||||
// Indented under the addon's name (16 + 32 + 12 px), one step quieter than it.
|
||||
<li className="flex min-h-[44px] items-center gap-2.5 py-1.5 pe-4 ps-[60px]">
|
||||
{icon
|
||||
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
|
||||
: <span className="w-6 flex-none" aria-hidden />}
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
|
||||
import { Children, type ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { HintMark } from '../Settings/settingsKit'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
|
||||
function enabledRows(rows: ReactNode[]): number {
|
||||
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
|
||||
}
|
||||
|
||||
/**
|
||||
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
|
||||
* icon tile on the left, name and description beside it and the switch on the
|
||||
* right. Its sub-features sit under it in a grey inset box of hairline rows.
|
||||
* One addon in its type's card, as a settings row: the icon tile, the name with
|
||||
* its description behind an info mark, and the switch on the right. Its
|
||||
* sub-features follow as indented rows while it is on, in the same list and with
|
||||
* the same hairlines: no box of their own, nothing folded away.
|
||||
*
|
||||
* The on/off state rides on the icon tile and the switch: a switched-off addon
|
||||
* greys its tile and its description, but keeps its name at full strength. The
|
||||
* name is the tile's identity, not its state; greying it out reads as "broken"
|
||||
* rather than "not enabled".
|
||||
*
|
||||
* The row is not a click target, only the switches are. No lift, no pointer
|
||||
* cursor, no `role="button"`.
|
||||
*
|
||||
* A shelf of more than one row starts folded behind a bar that says how many
|
||||
* options there are and how many are on, so a page of addons stays short and
|
||||
* the options are still plain to see.
|
||||
* greys its tile but keeps its name at full strength. The name is the row's
|
||||
* identity, not its state; greying it out reads as "broken" rather than "not
|
||||
* enabled". The row is not a click target, only the switches are.
|
||||
*/
|
||||
export default function AddonTile({
|
||||
icon,
|
||||
@@ -32,90 +20,42 @@ export default function AddonTile({
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
trailing,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
/** The sub-shelf: `<AddonSubRow>` children. */
|
||||
/** The addon's switch; leave it out for a row that shows a state instead (`trailing`). */
|
||||
onToggle?: () => void
|
||||
/** Shown in place of the switch. */
|
||||
trailing?: ReactNode
|
||||
/** The sub-features: `<AddonSubRow>` children. */
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const shelfId = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const rows = Children.toArray(children)
|
||||
const foldable = rows.length > 1
|
||||
const active = enabledRows(rows)
|
||||
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
<article>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
// overflow-hidden so a brand mark that fills the slot keeps the slot's
|
||||
// rounded corners instead of squaring them off.
|
||||
className={`grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
|
||||
enabled ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
|
||||
className={`grid h-8 w-8 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
|
||||
enabled ? 'bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')} title={name}>
|
||||
{name}
|
||||
</h4>
|
||||
<p
|
||||
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
style={fs(11.5)}
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<h4 className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>{name}</h4>
|
||||
{description && <HintMark hint={description} />}
|
||||
</div>
|
||||
|
||||
<div className="flex-none pt-1.5">
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
|
||||
<div className="flex flex-none items-center">
|
||||
{trailing ?? (onToggle && <ToggleSwitch on={enabled} onToggle={onToggle} label={name} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
{foldable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-controls={shelfId}
|
||||
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
|
||||
>
|
||||
{/* The same slot and type as a row below, so the bar reads as one more row. */}
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<ChevronDown
|
||||
size={15}
|
||||
strokeWidth={2.2}
|
||||
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.subOptions', { count: rows.length })}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
|
||||
style={fs(10.5, 'body')}
|
||||
>
|
||||
{t('admin.addons.subOptionsOn', { count: active })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Sitting inside the parent on its own fill is enough to say these belong
|
||||
to it: no rail, no indent guides. */}
|
||||
{(!foldable || open) && (
|
||||
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* An empty shelf (a tours addon without its options here) draws no line. */}
|
||||
{Children.count(children) > 0 && <ul className="m-0 list-none divide-y divide-edge-faint border-t border-edge-faint p-0">{children}</ul>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
|
||||
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill, SettingsColumns } from '../Settings/settingsKit'
|
||||
import { SCOPES_PREVIEW, useMcpTokensAdmin } from './useMcpTokensAdmin'
|
||||
|
||||
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'
|
||||
@@ -72,6 +72,9 @@ export default function AdminMcpTokensPanel() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* The sessions and the tokens side by side: two lists of the same kind. */}
|
||||
<SettingsColumns
|
||||
left={<>
|
||||
{/* OAuth Sessions. The tab's own subtitle sits in its band: the sidebar
|
||||
already names the tab, so a bare heading over the cards only repeated it. */}
|
||||
<SettingsCard
|
||||
@@ -116,7 +119,8 @@ export default function AdminMcpTokensPanel() {
|
||||
</SettingRows>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
</>}
|
||||
right={<>
|
||||
{/* MCP Tokens */}
|
||||
<SettingsCard
|
||||
icon={KeyRound}
|
||||
@@ -147,6 +151,8 @@ export default function AdminMcpTokensPanel() {
|
||||
</SettingRows>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</>}
|
||||
/>
|
||||
|
||||
{/* Revoke OAuth session */}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function AdminPluginsPanel() {
|
||||
<input id="plugin-dev-link" value={linkPath} onChange={(e) => setLinkPath(e.target.value)} spellCheck={false}
|
||||
placeholder={t('admin.plugins.devLinkPathPlaceholder')} className={`${INPUT} font-mono`} />
|
||||
</EditorField>
|
||||
<button type="submit" disabled={!linkPath.trim() || busy === '__link'} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="submit" disabled={!linkPath.trim() || busy === '__link'} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Link2 size={14} /> {t('admin.plugins.devLinkButton')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -223,7 +223,7 @@ export default function AdminPluginsPanel() {
|
||||
surface="var(--bg-secondary)"
|
||||
size={88}
|
||||
action={(
|
||||
<button type="button" onClick={openDiscover} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={openDiscover} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Download size={14} /> {t('admin.plugins.tabDiscover')}
|
||||
</button>
|
||||
)}
|
||||
@@ -627,7 +627,7 @@ function EgressDialog({ data, draft, setDraft, saving, error, onSave, onClose, t
|
||||
className={`${INPUT} flex-1 font-mono`}
|
||||
/>
|
||||
<button type="button" disabled={saving || !draft.trim()} onClick={() => onSave([...data.hosts, draft.trim()])}
|
||||
className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -68,14 +68,14 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
icon={ClipboardList}
|
||||
title={t('admin.tabs.audit')}
|
||||
hint={t('admin.audit.subtitle')}
|
||||
badge={<StatusPill>{t('admin.audit.showing', { count: entries.length, total })}</StatusPill>}
|
||||
// The count only once there is one: never "0 of 0" while the first page loads.
|
||||
badge={total > 0 ? <StatusPill>{t('admin.audit.showing', { count: entries.length, total })}</StatusPill> : undefined}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={2.2} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
@@ -91,7 +91,6 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
|
||||
@@ -56,7 +56,6 @@ export default function BackupPanel() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||
>
|
||||
{isUploading
|
||||
@@ -69,7 +68,6 @@ export default function BackupPanel() {
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12.5, 'body')}
|
||||
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||
>
|
||||
{isCreating
|
||||
@@ -97,7 +95,7 @@ export default function BackupPanel() {
|
||||
surface="var(--bg-secondary)"
|
||||
className="!py-6"
|
||||
action={
|
||||
<button type="button" onClick={handleCreate} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={handleCreate} className={SETTINGS_BUTTON}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
@@ -295,7 +293,6 @@ export default function BackupPanel() {
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{autoSettingsSaving
|
||||
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function CategoryManager() {
|
||||
hint={t('categories.subtitle')}
|
||||
badge={!isLoading && categories.length > 0 ? <StatusPill>{categories.length}</StatusPill> : undefined}
|
||||
action={
|
||||
<button type="button" onClick={handleStartCreate} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={handleStartCreate} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { Loader2, Map as MapIcon, Settings2, Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from '../Settings/Section'
|
||||
import { SettingRows, SettingsHint } from '../Settings/settingsKit'
|
||||
import { SettingRow, SettingRows, SettingsColumns } from '../Settings/settingsKit'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -34,15 +34,8 @@ function ChoiceRow({
|
||||
stacked?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={stacked ? 'flex flex-col gap-2.5 px-3.5 py-3' : 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'}>
|
||||
<label className={`block font-medium text-content ${stacked ? '' : 'min-w-0 flex-1 basis-40'}`} style={fs(13, 'body')}>
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="-mt-2 m-0 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
<div className={stacked ? 'min-w-0' : 'flex min-w-0 flex-none items-center'}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
// The settings row every page uses, so a default reads like the setting it presets.
|
||||
return <SettingRow label={label} hint={hint} control={children} stacked={stacked} />
|
||||
}
|
||||
|
||||
/** A wide field box inside the white rows, for the map card's token and style. */
|
||||
@@ -114,23 +107,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const darkMode = defaults.dark_mode
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHint className="mb-4">
|
||||
{t('admin.defaultSettings.description')}
|
||||
</SettingsHint>
|
||||
<SettingsColumns
|
||||
left={
|
||||
<>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
stayed empty. Grouped by subject rather than by height — what a user ends up
|
||||
seeing on the left, everything that configures the map on the right.
|
||||
The columns are deliberately uneven: the left one never needs more than the
|
||||
widest option row, while the right one holds four text fields whose values
|
||||
are 60-character tile URLs. Only the column gap is set, because Section
|
||||
carries its own bottom margin and a row gap would double it once the grid
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2} hint={t('admin.defaultSettings.description')}>
|
||||
<SettingRows>
|
||||
{/* Color Mode */}
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
|
||||
@@ -207,7 +188,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>} hint={t('settings.currencyHint')}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
@@ -232,9 +213,17 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
</ChoiceRow>
|
||||
</SettingRows>
|
||||
<SettingsHint className="-mt-2">{t('settings.currencyHint')}</SettingsHint>
|
||||
</Section>
|
||||
|
||||
{/* Which engines answer routes, under the defaults: on its own card it keeps
|
||||
the two columns about as long as each other. */}
|
||||
{!managed && (
|
||||
<Section title={t('admin.defaultSettings.routing')} icon={Route}>
|
||||
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
|
||||
@@ -270,7 +259,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />}
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-[12px] border border-edge-faint">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
@@ -386,7 +374,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
)}
|
||||
</SettingRows>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,6 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${SETTINGS_BUTTON} no-underline`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={13} strokeWidth={2.2} />
|
||||
GitHub
|
||||
@@ -298,7 +297,6 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{loadingMore ? <Loader2 size={13} className="animate-spin" /> : <ChevronDown size={13} strokeWidth={2.2} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
import { AlertTriangle, Check } from 'lucide-react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { INPUT } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON_PRIMARY, StatusPill } from '../Settings/settingsKit'
|
||||
import { HintMark, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
import { useGoogleQuota } from './useGoogleQuota'
|
||||
|
||||
/**
|
||||
* The daily ceiling on Google calls (#1582), as one row of the Google options:
|
||||
* today's count as a pill beside the name, the number on the right, a save
|
||||
* today's count as a quiet line under the name, the number on the right, a save
|
||||
* button only while there is something to save. Laid out like settingsKit's
|
||||
* SettingRow, but with the pill outside the <label> so the field keeps the
|
||||
* plain name as its accessible name.
|
||||
* SettingRow; the count stays outside the <label>, so the field keeps the plain
|
||||
* name as its accessible name.
|
||||
*/
|
||||
export default function GoogleDailyLimitRow() {
|
||||
const { t } = useTranslation()
|
||||
const { status, draft, setDraft, dirty, saving, save } = useGoogleQuota()
|
||||
|
||||
return (
|
||||
<div className={`px-3.5 py-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<div className={`px-4 py-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="min-w-0 flex-1 basis-56">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<label htmlFor="admin-google-daily-limit" className="font-medium text-content" style={fs(13, 'body')}>{t('admin.googleQuota.title')}</label>
|
||||
{/* Today's count beside the name it belongs to: a green dot while there is room, amber once the day is used up. */}
|
||||
{status && (
|
||||
status.exhausted ? (
|
||||
<StatusPill tone="warning" icon={<AlertTriangle size={11} strokeWidth={2.4} />}>
|
||||
{t('admin.googleQuota.reached', { used: status.used_today })}
|
||||
</StatusPill>
|
||||
) : (
|
||||
<StatusPill icon={<span aria-hidden className="me-0.5 h-1.5 w-1.5 rounded-full bg-success" />}>
|
||||
{status.daily_limit == null
|
||||
? t('admin.googleQuota.usedToday', { used: status.used_today })
|
||||
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
|
||||
</StatusPill>
|
||||
)
|
||||
)}
|
||||
<HintMark hint={t('admin.googleQuota.subtitle')} />
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{t('admin.googleQuota.subtitle')}</p>
|
||||
{/* Today's count as plain text under the name; in the warning colour once the day is used up. */}
|
||||
{status && (
|
||||
<p className={`m-0 mt-0.5 tabular-nums ${status.exhausted ? 'text-warning' : 'text-content-faint'}`} style={fs(11.5)}>
|
||||
{status.exhausted
|
||||
? t('admin.googleQuota.reached', { used: status.used_today })
|
||||
: status.daily_limit == null
|
||||
? t('admin.googleQuota.usedToday', { used: status.used_today })
|
||||
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-2">
|
||||
<div className="w-32">
|
||||
@@ -55,7 +51,7 @@ export default function GoogleDailyLimitRow() {
|
||||
</div>
|
||||
{dirty && (
|
||||
<button type="button" onClick={() => void save()} disabled={saving}
|
||||
className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Check size={14} strokeWidth={2.2} />
|
||||
{t('common.save')}
|
||||
</button>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function PackingTemplateManager() {
|
||||
hint={t('admin.packingTemplates.subtitle')}
|
||||
badge={!isLoading && templates.length > 0 ? <StatusPill>{templates.length}</StatusPill> : undefined}
|
||||
action={
|
||||
<button type="button" onClick={() => setShowCreate(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={() => setShowCreate(true)} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Plus size={14} strokeWidth={2.2} /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PlaceContribField, PlaceContribState } from '@trek/shared';
|
||||
import { ChevronRight, HeartHandshake, Undo2 } from 'lucide-react';
|
||||
import { HeartHandshake, Undo2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { TranslationFn } from '../../types';
|
||||
@@ -152,19 +152,13 @@ export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hid
|
||||
)}
|
||||
|
||||
{(agreed || state.consent.decision === 'off') && (
|
||||
<details className="group overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* The admin page's fold head (pages/admin/FoldSummary), which a component cannot import. */}
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className="flex-none text-content-faint transition-transform group-open:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* A plain section head: the settings fold nothing away. */}
|
||||
<div className="flex items-center gap-2.5 px-3.5 py-2.5">
|
||||
<span className="font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.contribute.log.title')}
|
||||
</span>
|
||||
</summary>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-edge-faint p-3">
|
||||
<PlaceContribLog />
|
||||
<button
|
||||
@@ -178,7 +172,7 @@ export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hid
|
||||
{t('admin.contribute.withdraw')}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPrompt && state.eligible && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ValhallaProvider } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { INPUT, LABEL } from '../shared/dialogParts'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
@@ -121,10 +122,20 @@ function RoutingProviderField({ defaults, onSave, onReset, hintClassName }: Rout
|
||||
<div className={look.head}>
|
||||
<label htmlFor={id} className={look.label}>{t('settings.valhallaProvider')}</label>
|
||||
</div>
|
||||
<select id={id} value={defaults.routing_valhalla_provider ?? ''} disabled={saving}
|
||||
onChange={e => void pick(e.target.value)} aria-describedby={`${id}-hint`} className={look.input}>
|
||||
{PROVIDERS.map(option => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
|
||||
</select>
|
||||
{phone ? (
|
||||
<select id={id} value={defaults.routing_valhalla_provider ?? ''} disabled={saving}
|
||||
onChange={e => void pick(e.target.value)} aria-describedby={`${id}-hint`} className={look.input}>
|
||||
{PROVIDERS.map(option => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
// The desktop's own select, like every other choice on its settings pages.
|
||||
<CustomSelect
|
||||
value={defaults.routing_valhalla_provider ?? ''}
|
||||
onChange={value => void pick(String(value))}
|
||||
options={PROVIDERS.map(option => ({ value: option.value, label: t(option.labelKey) }))}
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
<FieldHint id={`${id}-hint`} text={t('settings.valhallaProviderHint')} hintClassName={hintClassName} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ function Catalog() {
|
||||
{state.loading && <p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}><Loader2 size={14} className="flex-none animate-spin" />{t('common.loading')}</p>}
|
||||
{state.error && !state.editor && <div role="alert" className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger" style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 basis-48">{state.error}</span>
|
||||
<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>{t('schoolCatalog.retry')}</button>
|
||||
<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={SETTINGS_BUTTON}>{t('schoolCatalog.retry')}</button>
|
||||
</div>}
|
||||
<fieldset disabled={disabled} className={`m-0 flex min-w-0 flex-col gap-4 border-0 p-0 ${state.busy ? 'opacity-60' : ''}`}>
|
||||
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
|
||||
@@ -59,7 +59,7 @@ function Catalog() {
|
||||
</Tooltip>
|
||||
</div>}
|
||||
{!adding && state.catalog.countries.length === 0 && <div>
|
||||
<button type="button" onClick={() => setAdding(true)} className={SETTINGS_BUTTON} style={fs(13, 'body')}><Plus size={14} strokeWidth={2.2} />{t('schoolCatalog.addCountry')}</button>
|
||||
<button type="button" onClick={() => setAdding(true)} className={SETTINGS_BUTTON}><Plus size={14} strokeWidth={2.2} />{t('schoolCatalog.addCountry')}</button>
|
||||
</div>}
|
||||
{adding && <form className={PANEL} onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
@@ -74,8 +74,8 @@ function Catalog() {
|
||||
</EditorField>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setAdding(false)} className={SETTINGS_BUTTON} style={fs(13, 'body')}>{t('common.cancel')}</button>
|
||||
<button type="submit" className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>{t('common.save')}</button>
|
||||
<button type="button" onClick={() => setAdding(false)} className={SETTINGS_BUTTON}>{t('common.cancel')}</button>
|
||||
<button type="submit" className={SETTINGS_BUTTON_PRIMARY}>{t('common.save')}</button>
|
||||
</div>
|
||||
</form>}
|
||||
{selectedCountry && <>
|
||||
|
||||
@@ -39,11 +39,10 @@ describe('ServiceIdentityPanel', () => {
|
||||
const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.service.places' });
|
||||
expect(bar).toHaveAttribute('aria-valuenow', '2500');
|
||||
expect(bar).toHaveAttribute('aria-valuemax', '10000');
|
||||
// What is left is the figure of the tile; used and the limit sit beside it as badges.
|
||||
expect(screen.getByText('7,500')).toBeInTheDocument();
|
||||
expect(screen.getByText('2,500')).toBeInTheDocument();
|
||||
expect(screen.getByText('10,000')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.trekApi.quota.limit')).toBeInTheDocument();
|
||||
// One row per service: used of the limit beside the bar, what is left in its title.
|
||||
const figure = screen.getByText('2,500').parentElement as HTMLElement;
|
||||
expect(figure).toHaveTextContent('2,500 / 10,000');
|
||||
expect(figure).toHaveAttribute('title', 'admin.trekApi.quota.remaining: 7,500');
|
||||
// The day starts again at the same time for every service, so it is said once, in the header.
|
||||
expect(screen.getAllByText('admin.trekApi.quota.resets')).toHaveLength(1);
|
||||
expect(screen.queryByText(/admin\.trekApi\.quota\.hint\./)).toBeNull();
|
||||
|
||||
@@ -64,44 +64,39 @@ function ServiceTile({ allowance }: { allowance: ServiceAllowance }) {
|
||||
const Icon = SERVICE_ICON[allowance.service] ?? MapPin;
|
||||
const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100;
|
||||
|
||||
// One row per service: its name, the day's use as a bar, used of the limit.
|
||||
return (
|
||||
<li
|
||||
className={`flex min-w-0 flex-col gap-2.5 rounded-xl border border-edge-faint bg-surface-card p-3 shadow-sm ${allowance.inUse ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-surface-tertiary text-content-muted">
|
||||
<Icon size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<li className={`flex min-w-0 flex-col gap-1 px-3 py-2.5 ${allowance.inUse ? '' : 'opacity-60'}`}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex w-[34%] min-w-0 flex-none items-center gap-2">
|
||||
<Icon size={14} strokeWidth={2} aria-hidden="true" className="flex-none text-content-muted" />
|
||||
<span className="truncate text-caption font-medium text-content-secondary">{name}</span>
|
||||
</span>
|
||||
<p className="m-0 truncate text-caption font-medium text-content-secondary">{name}</p>
|
||||
</div>
|
||||
|
||||
{quota && (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className={`m-0 truncate text-title font-semibold tabular-nums leading-tight ${figureTone(quota)}`}>
|
||||
{number.format(quota.remaining)}
|
||||
</p>
|
||||
<p className="m-0 text-caption text-content-muted">{t('admin.trekApi.quota.remaining')}</p>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={name}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={quota.limit}
|
||||
aria-valuenow={quota.used}
|
||||
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
|
||||
>
|
||||
{quota && (
|
||||
<>
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<FigureBadge label={t('admin.trekApi.quota.used')} value={number.format(quota.used)} />
|
||||
<FigureBadge label={t('admin.trekApi.quota.limit')} value={number.format(quota.limit)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
role="progressbar"
|
||||
aria-label={name}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={quota.limit}
|
||||
aria-valuenow={quota.used}
|
||||
className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-tertiary"
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="flex-none text-caption tabular-nums text-content-faint"
|
||||
title={`${t('admin.trekApi.quota.remaining')}: ${number.format(quota.remaining)}`}
|
||||
>
|
||||
<span className={`font-semibold ${figureTone(quota)}`}>{number.format(quota.used)}</span>
|
||||
{' / '}{number.format(quota.limit)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{note && <p className="m-0 text-caption leading-snug text-content-muted">{note}</p>}
|
||||
</li>
|
||||
);
|
||||
@@ -172,7 +167,7 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
|
||||
{status && status.services.length > 0 && (
|
||||
<ul
|
||||
aria-label={t('admin.trekApi.quota.title')}
|
||||
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
|
||||
className="m-0 flex list-none flex-col divide-y divide-edge-faint overflow-hidden rounded-xl border border-edge-faint bg-surface-card p-0"
|
||||
>
|
||||
{status.services.map((allowance) => (
|
||||
<ServiceTile key={allowance.service} allowance={allowance} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from '../../../i18n';
|
||||
import { StatusPill } from '../../Settings/settingsKit';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import AddonSubRow from '../AddonSubRow';
|
||||
import AddonTile from '../AddonTile';
|
||||
import { type Addon, getAddonLabel } from './addonModel';
|
||||
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
|
||||
import AiSettingsDialog from './AiAdminConfig';
|
||||
@@ -26,72 +27,53 @@ export default function AiAddonTile({
|
||||
const tile = useAiAddonTile(addons);
|
||||
const anyOn = tile.enabledCount > 0;
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
|
||||
anyOn
|
||||
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
|
||||
: 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{t('admin.addons.ai.name')}
|
||||
</h4>
|
||||
<p
|
||||
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('admin.addons.ai.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-none pt-1">
|
||||
<>
|
||||
<AddonTile
|
||||
icon={<Sparkles size={15} />}
|
||||
name={t('admin.addons.ai.name')}
|
||||
description={t('admin.addons.ai.description')}
|
||||
enabled={anyOn}
|
||||
trailing={
|
||||
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
|
||||
{tile.enabledCount}/{addons.length}
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
|
||||
{addons.map((addon) => {
|
||||
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
|
||||
const label = getAddonLabel(t, addon);
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={addon.id}
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
{addons.map((addon) => {
|
||||
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
|
||||
const label = getAddonLabel(t, addon);
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={addon.id}
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tile.canSetUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tile.openDialog}
|
||||
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<Settings2 size={14} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.ai.setup')}
|
||||
</span>
|
||||
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
|
||||
</button>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={tile.openDialog}
|
||||
className="flex min-h-[44px] w-full items-center gap-2.5 border-0 bg-transparent py-1.5 pe-4 ps-[60px] text-start transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none"
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<Settings2 size={14} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.ai.setup')}
|
||||
</span>
|
||||
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</AddonTile>
|
||||
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||
type="button"
|
||||
onClick={cancelReplace}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
|
||||
@@ -61,7 +61,6 @@ function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onO
|
||||
onClick={() => void P.switchToShared()}
|
||||
disabled={P.saving}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.useShared')}
|
||||
</button>
|
||||
@@ -166,7 +165,6 @@ function LocalModels({ P }: { P: Parsing }) {
|
||||
onClick={() => P.setModel(m.id)}
|
||||
disabled={chosen}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
|
||||
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
|
||||
@@ -177,7 +175,6 @@ function LocalModels({ P }: { P: Parsing }) {
|
||||
onClick={() => void P.pull(m.id)}
|
||||
disabled={!!P.pulling}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
|
||||
</button>
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Mail,
|
||||
PlugZap,
|
||||
Save,
|
||||
Upload,
|
||||
Webhook,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle, Copy, Loader2, Mail, PlugZap, Save, XCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { TranslationFn } from '../../../types';
|
||||
import { copyText } from '../../../utils/clipboard';
|
||||
import { relativeTime } from '../../../utils/relativeTime';
|
||||
import {
|
||||
HintMark,
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SettingRow,
|
||||
SettingRows,
|
||||
SettingsCard,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
@@ -45,9 +32,6 @@ import { useMailIngestAdmin, type MailIngestAdmin } from './useMailIngestAdmin';
|
||||
|
||||
type T = TranslationFn;
|
||||
|
||||
/** The card's buttons sit inside the form, one size below the page's: caption type, tighter padding. */
|
||||
const SMALL = 'px-3 py-1.5 text-caption';
|
||||
|
||||
/** The address a mail service posts to, shown so the admin can paste it into the sender's settings. */
|
||||
function InboundUrl({ t }: { t: T }) {
|
||||
const toast = useToast();
|
||||
@@ -58,7 +42,7 @@ function InboundUrl({ t }: { t: T }) {
|
||||
<span className={READONLY_BOX}>{url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${SETTINGS_BUTTON} ${SMALL}`}
|
||||
className={SETTINGS_BUTTON}
|
||||
aria-label={t('mailAdmin.copyUrl')}
|
||||
onClick={() => void copyText(url).then((ok) => (ok ? toast.success(t('common.copied')) : undefined))}
|
||||
>
|
||||
@@ -171,43 +155,18 @@ function WebhookFields({ admin, form, t, error }: FieldsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const STAT_TONE: Record<StatTone, { icon: string; value: string }> = {
|
||||
neutral: { icon: 'bg-surface-tertiary text-content-muted', value: 'text-content' },
|
||||
success: { icon: 'bg-success-soft text-success', value: 'text-content' },
|
||||
warning: { icon: 'bg-warning-soft text-warning', value: 'text-content' },
|
||||
danger: { icon: 'bg-danger-soft text-danger', value: 'text-danger' },
|
||||
/** The colour a count takes once it is not zero. */
|
||||
const STAT_COLOUR: Record<StatTone, string> = {
|
||||
neutral: 'text-content',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
};
|
||||
|
||||
/** One number of the status: its icon in the tone's soft colour, the count large, the label under it. */
|
||||
function StatTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
tone: StatTone;
|
||||
}) {
|
||||
const look = STAT_TONE[tone];
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1.5 shadow-sm">
|
||||
<span className={`grid h-6 w-6 flex-none place-items-center rounded-full ${look.icon}`}>
|
||||
<Icon size={12} strokeWidth={2.2} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-body font-semibold tabular-nums leading-tight ${look.value}`}>{value}</div>
|
||||
<div className="truncate text-caption leading-tight text-content-faint">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the import runs, in counts only: no subject, sender or content of a mail
|
||||
* is ever shown here. A head line with the last successful read (green, or
|
||||
* amber while the last run failed), then the day's numbers as tiles.
|
||||
* is ever shown here. One line: the last successful read (green, or amber while
|
||||
* the last run failed), then the day's numbers as badges.
|
||||
*/
|
||||
function StatusBlock({ admin, t, locale }: { admin: MailIngestAdmin; t: T; locale: string }) {
|
||||
const status = admin.status;
|
||||
@@ -218,21 +177,25 @@ function StatusBlock({ admin, t, locale }: { admin: MailIngestAdmin; t: T; local
|
||||
: t('mailAdmin.status.never');
|
||||
const healthy = !status.last_error && !!status.last_ok_at;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-edge-faint bg-surface-secondary p-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-caption font-semibold uppercase tracking-[0.08em] text-content-faint">
|
||||
{t('mailAdmin.status.title')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-caption font-medium text-content-secondary">
|
||||
<span className={`h-2 w-2 rounded-full ${healthy ? 'bg-success' : 'bg-warning'}`} />
|
||||
{t('mailAdmin.status.lastRead', { time: lastRead })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5 max-md:grid-cols-2">
|
||||
{statTiles(facts, t).map(({ key, ...tile }) => (
|
||||
<StatTile key={key} {...tile} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* When it last read, as a plain line with a dot: green while it runs, amber after a failed run. */}
|
||||
<p className="m-0 flex items-center gap-1.5 text-caption text-content-muted">
|
||||
<span className={`h-1.5 w-1.5 flex-none rounded-full ${healthy ? 'bg-success' : 'bg-warning'}`} />
|
||||
{t('mailAdmin.status.lastRead', { time: lastRead })}
|
||||
</p>
|
||||
{/* The day's counts as one quiet strip: the number over its name, coloured only when it is not zero. */}
|
||||
<dl className="m-0 grid grid-cols-2 divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card sm:grid-cols-4 sm:divide-x">
|
||||
{statTiles(facts, t).map(({ key, label, value, tone }) => (
|
||||
<div key={key} className="flex min-w-0 flex-col-reverse px-3 py-2">
|
||||
<dt className="truncate text-caption text-content-faint">{label}</dt>
|
||||
<dd
|
||||
className={`m-0 text-body font-semibold tabular-nums ${value > 0 ? STAT_COLOUR[tone] : 'text-content'}`}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dl>
|
||||
{status.last_error && (
|
||||
<p className="m-0 flex items-start gap-1.5 rounded-lg bg-warning-soft px-3 py-2 text-caption leading-snug text-warning">
|
||||
<AlertTriangle size={13} strokeWidth={2.2} className="mt-px flex-none" />
|
||||
@@ -256,31 +219,20 @@ function TestResult({ admin, t }: { admin: MailIngestAdmin; t: T }) {
|
||||
);
|
||||
}
|
||||
|
||||
const TRANSPORT_ICON = { off: Upload, imap: Inbox, webhook: Webhook } as const;
|
||||
|
||||
/** What the chosen way of taking mail in means, as a calm blue note under the choice. */
|
||||
function TransportInfo({ transport, t }: { transport: MailAdminForm['transport']; t: T }) {
|
||||
const Icon = TRANSPORT_ICON[transport];
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-[color-mix(in_srgb,var(--info)_22%,transparent)] bg-info-soft px-3.5 py-3">
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-full bg-[color-mix(in_srgb,var(--info)_16%,transparent)] text-info">
|
||||
<Icon size={15} strokeWidth={2.1} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="m-0 text-caption font-semibold text-info">{transportLabel(transport, t)}</p>
|
||||
<p className="m-0 mt-0.5 text-caption leading-snug text-content-secondary">{transportInfo(transport, t)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything under the switch: how mail arrives, its fields, then save, test and the status. */
|
||||
function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailAdminForm; t: T; locale: string }) {
|
||||
const error = (problem: MailAdminProblem) => (admin.problems.includes(problem) ? problemText(problem, t) : undefined);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('mailAdmin.transport.label')}>
|
||||
<EditorField
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('mailAdmin.transport.label')}
|
||||
<HintMark hint={`${transportLabel(form.transport, t)}: ${transportInfo(form.transport, t)}`} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<CustomSelect
|
||||
value={form.transport}
|
||||
onChange={(value) => admin.set('transport', TRANSPORTS.find((x) => x === value) ?? 'off')}
|
||||
@@ -288,9 +240,13 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label={t('mailAdmin.aiDaily')}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('mailAdmin.aiDaily')}
|
||||
<HintMark hint={t('mailAdmin.aiDailyHint')} />
|
||||
</span>
|
||||
}
|
||||
htmlFor="mail-ai-daily"
|
||||
hint={t('mailAdmin.aiDailyHint')}
|
||||
error={error('dailyInvalid')}
|
||||
>
|
||||
<input
|
||||
@@ -302,11 +258,14 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<TransportInfo transport={form.transport} t={t} />
|
||||
<EditorField
|
||||
label={t('mailAdmin.template')}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('mailAdmin.template')}
|
||||
<HintMark hint={t('mailAdmin.templateHint', { token: '{token}' })} />
|
||||
</span>
|
||||
}
|
||||
htmlFor="mail-template"
|
||||
hint={t('mailAdmin.templateHint', { token: '{token}' })}
|
||||
error={error('templateToken')}
|
||||
>
|
||||
<input
|
||||
@@ -319,11 +278,10 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
|
||||
</EditorField>
|
||||
{form.transport === 'imap' && <ImapFields admin={admin} form={form} t={t} error={error} />}
|
||||
{form.transport === 'webhook' && <WebhookFields admin={admin} form={form} t={t} error={error} />}
|
||||
<SettingsHint>{t('mailAdmin.envHint')}</SettingsHint>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${SETTINGS_BUTTON_PRIMARY} ${SMALL}`}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
disabled={!admin.dirty || admin.problems.length > 0 || admin.saving}
|
||||
onClick={admin.save}
|
||||
>
|
||||
@@ -332,7 +290,7 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${SETTINGS_BUTTON} ${SMALL}`}
|
||||
className={SETTINGS_BUTTON}
|
||||
disabled={admin.testing || admin.dirty || form.transport === 'off'}
|
||||
onClick={admin.runTest}
|
||||
>
|
||||
@@ -340,6 +298,7 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
|
||||
{t('mailAdmin.test.run')}
|
||||
</button>
|
||||
<TestResult admin={admin} t={t} />
|
||||
<HintMark hint={t('mailAdmin.envHint')} />
|
||||
</div>
|
||||
<StatusBlock admin={admin} t={t} locale={locale} />
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,6 @@ function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: A
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.decline(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.paddleRegions.requests.decline')}
|
||||
</button>
|
||||
@@ -117,7 +116,6 @@ function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: A
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.approve(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
|
||||
{t('admin.paddleRegions.requests.approve')}
|
||||
|
||||
@@ -66,7 +66,7 @@ function Panel() {
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 basis-48">{admin.error}</span>
|
||||
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON}>
|
||||
{t('admin.paddleRegions.retry')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -159,7 +159,6 @@ function CatalogSearch({ admin }: { admin: Admin }) {
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.add(hit.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{admin.busyId === hit.id ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
|
||||
@@ -29,14 +29,7 @@ import CustomSelect from '../../shared/CustomSelect'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import { NEUTRAL_TINT, fs } from '../../shared/DialogShell'
|
||||
import Section from '../../Settings/Section'
|
||||
import {
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingRows,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill, SettingsColumns } from '../../Settings/settingsKit'
|
||||
import BackendForm from './BackendForm'
|
||||
import StorageReclaimSection from './StorageReclaimSection'
|
||||
import { CACHE_CATEGORIES, primaryNameOf } from './storageModel'
|
||||
@@ -142,6 +135,10 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two columns by length: the health and the backends on the left, which
|
||||
category goes where on the right, its save bar at the card's foot. */}
|
||||
<SettingsColumns
|
||||
left={<>
|
||||
<Section title={t('storage.health.title')} icon={Activity}>
|
||||
{state.health.replicaFailures.length === 0 ? (
|
||||
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
@@ -180,7 +177,6 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
<button type="button"
|
||||
onClick={startAdd}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('storage.backends.add')}
|
||||
@@ -486,7 +482,18 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</SettingRows>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Under the backends it cleans up after, which keeps the two columns even. */}
|
||||
<StorageReclaimSection
|
||||
reclaim={reclaim}
|
||||
checking={reclaimChecking}
|
||||
saving={reclaimSaving}
|
||||
confirming={reclaimConfirming}
|
||||
onConfirm={(request) => void handleReclaimConfirm(request)}
|
||||
onCheck={() => void handleReclaimCheck()}
|
||||
onDelete={(enabled) => void handleReclaimDelete(enabled)}
|
||||
/>
|
||||
</>}
|
||||
right={<>
|
||||
<Section title={t('storage.categories.title')} icon={FolderTree}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
@@ -544,26 +551,24 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Saving is part of the card it saves, under a hairline at its foot. */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-edge-faint pt-4">
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{admin.saving ? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
|
||||
{t('storage.save')}
|
||||
</button>
|
||||
{admin.dirty && <StatusPill tone="warning">{t('storage.unsaved')}</StatusPill>}
|
||||
{migrationQueue.length > 0 && (
|
||||
<SettingsHint className="min-w-0 flex-1">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</SettingsHint>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-3">
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{admin.saving ? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
|
||||
{t('storage.save')}
|
||||
</button>
|
||||
{admin.dirty && <StatusPill tone="warning">{t('storage.unsaved')}</StatusPill>}
|
||||
{migrationQueue.length > 0 && (
|
||||
<SettingsHint className="min-w-0 flex-1">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</SettingsHint>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{migratePromptOpen && (
|
||||
<div
|
||||
role="alertdialog"
|
||||
@@ -586,7 +591,6 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-3">
|
||||
<button type="button"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
onClick={closeMigratePrompt}
|
||||
>
|
||||
{t('storage.migrate.promptCancel')}
|
||||
@@ -594,14 +598,12 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
onClick={() => void routeOnlySave()}
|
||||
>
|
||||
{t('storage.migrate.routeOnly')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
onClick={() => void moveAndSave()}
|
||||
>
|
||||
{t('storage.migrate.move')}
|
||||
@@ -609,6 +611,8 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
/>
|
||||
{admin.saveError && (
|
||||
<div role="alert" className="mt-3 flex flex-wrap items-start gap-3 rounded-2xl border border-edge-faint bg-danger-soft px-4 py-3">
|
||||
<AlertTriangle size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-danger" />
|
||||
@@ -629,17 +633,6 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<StorageReclaimSection
|
||||
reclaim={reclaim}
|
||||
checking={reclaimChecking}
|
||||
saving={reclaimSaving}
|
||||
confirming={reclaimConfirming}
|
||||
onConfirm={(request) => void handleReclaimConfirm(request)}
|
||||
onCheck={() => void handleReclaimCheck()}
|
||||
onDelete={(enabled) => void handleReclaimDelete(enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<BackendForm
|
||||
|
||||
@@ -50,7 +50,6 @@ export default function StorageReclaimSection({
|
||||
<button
|
||||
type="button"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
onClick={onCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
@@ -132,7 +131,6 @@ export default function StorageReclaimSection({
|
||||
<button
|
||||
type="button"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
|
||||
disabled={confirming === line.category}
|
||||
>
|
||||
|
||||
@@ -42,7 +42,6 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className={`${SETTINGS_BUTTON} px-3 py-1.5`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
|
||||
</button>
|
||||
|
||||
@@ -9,9 +9,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { EditorField, GRID_2, INPUT, PANEL } from '../shared/dialogParts'
|
||||
import {
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
|
||||
} from './settingsKit'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill, SettingsColumns } from './settingsKit'
|
||||
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
|
||||
import AccountDemoLock from './AccountDemoLock'
|
||||
|
||||
@@ -76,6 +74,10 @@ export default function AccountTab(): React.ReactElement {
|
||||
<NativeServerSection />
|
||||
{/* The shared demo account: everything here is shown, nothing can be changed. */}
|
||||
<AccountDemoLock>
|
||||
{/* Two columns by length: who you are and the second factor on the left,
|
||||
the password and passkeys on the right. */}
|
||||
<SettingsColumns
|
||||
left={<>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
|
||||
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
|
||||
@@ -169,54 +171,6 @@ export default function AccountTab(): React.ReactElement {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Change Password */}
|
||||
{!oidcOnlyMode && (
|
||||
<Section title={t('settings.changePassword')} icon={Lock}>
|
||||
<EditorField label={t('settings.currentPassword')} htmlFor="account-current-password">
|
||||
<input
|
||||
id="account-current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('settings.newPassword')} htmlFor="account-new-password">
|
||||
<input
|
||||
id="account-new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder={t('settings.newPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.confirmPassword')} htmlFor="account-confirm-password">
|
||||
<input
|
||||
id="account-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('settings.confirmPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<PasswordChecklist password={newPassword} />
|
||||
<div className="flex justify-end" style={fs(13, 'body')}>
|
||||
<button type="button"
|
||||
onClick={changePassword}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* MFA */}
|
||||
<Section title={t('settings.mfa.title')} icon={KeyRound}>
|
||||
{mfaRequiredByPolicy && (
|
||||
@@ -236,7 +190,6 @@ export default function AccountTab(): React.ReactElement {
|
||||
disabled={mfaLoading}
|
||||
onClick={startMfaSetup}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{mfaLoading ? <span className={SPINNER} /> : <KeyRound size={14} />}
|
||||
{t('settings.mfa.setup')}
|
||||
@@ -296,7 +249,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
{user?.mfa_enabled && (
|
||||
<div className={PANEL}>
|
||||
<div>
|
||||
<p className={EYEBROW} style={fs(10)}>{t('settings.mfa.disableTitle')}</p>
|
||||
<p className={EYEBROW}>{t('settings.mfa.disableTitle')}</p>
|
||||
<SettingsHint className="mt-1">{t('settings.mfa.disableHint')}</SettingsHint>
|
||||
</div>
|
||||
<div className={GRID_2}>
|
||||
@@ -319,7 +272,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
className={`${INPUT} font-geist tabular-nums`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end" style={fs(13, 'body')}>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
@@ -335,14 +288,14 @@ export default function AccountTab(): React.ReactElement {
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className={PANEL}>
|
||||
<div>
|
||||
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.mfa.backupTitle')}</p>
|
||||
<p className="m-0 font-semibold text-content">{t('settings.mfa.backupTitle')}</p>
|
||||
<SettingsHint className="mt-0.5">{t('settings.mfa.backupDescription')}</SettingsHint>
|
||||
</div>
|
||||
<pre className="m-0 max-h-[220px] overflow-auto rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3 font-geist tabular-nums leading-relaxed tracking-wide text-content" style={fs(12.5, 'body')}>{backupCodesText}</pre>
|
||||
<p className="m-0 flex items-center gap-1.5 font-medium text-warning" style={fs(11.5)}>
|
||||
<pre className="m-0 max-h-[220px] overflow-auto rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3 font-geist tabular-nums leading-relaxed tracking-wide text-content">{backupCodesText}</pre>
|
||||
<p className="m-0 flex items-center gap-1.5 font-medium text-warning">
|
||||
<AlertTriangle size={13} className="flex-none" />{t('settings.mfa.backupWarning')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2" style={fs(12.5, 'body')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={copyBackupCodes} className={SETTINGS_BUTTON}>
|
||||
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||
</button>
|
||||
@@ -361,9 +314,60 @@ export default function AccountTab(): React.ReactElement {
|
||||
)}
|
||||
</>
|
||||
</Section>
|
||||
</>}
|
||||
right={<>
|
||||
{/* Change Password */}
|
||||
{!oidcOnlyMode && (
|
||||
<Section title={t('settings.changePassword')} icon={Lock}>
|
||||
<EditorField label={t('settings.currentPassword')} htmlFor="account-current-password">
|
||||
<input
|
||||
id="account-current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('settings.newPassword')} htmlFor="account-new-password">
|
||||
<input
|
||||
id="account-new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder={t('settings.newPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.confirmPassword')} htmlFor="account-confirm-password">
|
||||
<input
|
||||
id="account-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('settings.confirmPassword')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<PasswordChecklist password={newPassword} />
|
||||
<div className="flex justify-end" style={fs(13, 'body')}>
|
||||
<button type="button"
|
||||
onClick={changePassword}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Passkeys */}
|
||||
<PasskeysSection demoMode={demoMode} />
|
||||
</>}
|
||||
/>
|
||||
</AccountDemoLock>
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import AirTrailConnectionSection from './AirTrailConnectionSection';
|
||||
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
|
||||
|
||||
interface SavedBody {
|
||||
url: string;
|
||||
@@ -38,8 +37,6 @@ function toggleNextTo(label: string): HTMLElement {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
stubSettings();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAirTrailSettings } from './useAirTrailSettings'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, StatusPill } from './settingsKit'
|
||||
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
@@ -41,9 +41,10 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
return (
|
||||
<Section
|
||||
id="airtrail"
|
||||
collapsible
|
||||
title={t('settings.airtrail.title')}
|
||||
hint={t('settings.airtrail.hint')}
|
||||
icon={AirTrailIcon}
|
||||
iconFill
|
||||
badge={
|
||||
<StatusPill tone={connected ? 'success' : 'neutral'} icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>
|
||||
{connected ? t('settings.airtrail.connected') : t('settings.airtrail.notConnected')}
|
||||
@@ -107,7 +108,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SettingsHint>{t('settings.airtrail.hint')}</SettingsHint>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import ApiKeysSection from './ApiKeysSection';
|
||||
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
|
||||
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -22,8 +21,6 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
clipboardWriteText.mockReset();
|
||||
clipboardWriteText.mockResolvedValue(undefined);
|
||||
resetAllStores();
|
||||
|
||||
@@ -239,12 +239,11 @@ export default function ApiKeysSection(): React.ReactElement {
|
||||
<>
|
||||
<Section
|
||||
id="api-keys"
|
||||
collapsible
|
||||
title={t('settings.apiKeys.title')}
|
||||
icon={KeyRound}
|
||||
badge={keys.length > 0 ? <StatusPill>{keys.length}/{MAX_KEYS}</StatusPill> : undefined}
|
||||
action={(
|
||||
<button type="button" onClick={openModal} disabled={atLimit} className={SETTINGS_BUTTON_PRIMARY} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={openModal} disabled={atLimit} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Plus size={14} /> {t('settings.apiKeys.create')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DialogSection, fs } from '../shared/DialogShell'
|
||||
import { Segmented } from '../shared/dialogParts'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
|
||||
import { SETTINGS_BUTTON, SettingRow, SettingRows, SettingsColumns, StatusPill } from './settingsKit'
|
||||
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
|
||||
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
|
||||
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
|
||||
@@ -44,6 +44,8 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsColumns
|
||||
left={<>
|
||||
{/* ── Theme ───────────────────────────────────────────────── */}
|
||||
<Section title={tr('settings.appearance.theme', 'Theme')} icon={Paintbrush}>
|
||||
<SettingRows>
|
||||
@@ -70,7 +72,8 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
|
||||
{/* Color scheme swatches */}
|
||||
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{/* As many per line as fit at a width that holds every name in full. */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2">
|
||||
{APPEARANCE_SCHEMES.map((s) => {
|
||||
const active = cfg.schemeId === s.id
|
||||
const dot = isDark ? s.swatch.dark : s.swatch.light
|
||||
@@ -208,6 +211,8 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
</SettingRows>
|
||||
</Section>
|
||||
|
||||
</>}
|
||||
right={<>
|
||||
{/* ── Dashboard widgets ───────────────────────────────────── */}
|
||||
<Section
|
||||
title={tr('settings.appearance.dashboardWidgets', 'Dashboard widgets')}
|
||||
@@ -236,12 +241,12 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
hint={tr('settings.appearance.sidebarHint', 'The whole right column. Turn off and the dashboard centers.')}
|
||||
control={<ToggleSwitch on={masterOn} onToggle={() => setWidget('desktop', g.master as string, !masterOn)}
|
||||
label={tr(`settings.appearance.widget.${g.master}`, WIDGET_LABELS[g.master])} />}
|
||||
>
|
||||
{/* Its widgets only matter while the column is shown: dimmed, not hidden, while it is off. */}
|
||||
<SettingRows className={`bg-surface-secondary transition-opacity ${masterOn ? '' : 'pointer-events-none opacity-40'}`}>
|
||||
{rows}
|
||||
</SettingRows>
|
||||
</SettingRow>
|
||||
/>
|
||||
{/* Its widgets, indented under it in the same block. They only matter while the
|
||||
column is shown: dimmed, not hidden, while it is off. */}
|
||||
<div className={`divide-y divide-edge-faint ps-6 transition-opacity ${masterOn ? '' : 'pointer-events-none opacity-40'}`}>
|
||||
{rows}
|
||||
</div>
|
||||
</SettingRows>
|
||||
)
|
||||
})}
|
||||
@@ -265,9 +270,11 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
</DialogSection>
|
||||
</Section>
|
||||
</>}
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex justify-end">
|
||||
<button type="button" onClick={resetAll} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={resetAll} className={SETTINGS_BUTTON}>
|
||||
<RotateCcw size={14} strokeWidth={2} />
|
||||
{tr('settings.appearance.reset', 'Reset to defaults')}
|
||||
</button>
|
||||
|
||||
@@ -46,7 +46,6 @@ import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import DawarichConnectionSection from './DawarichConnectionSection';
|
||||
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() };
|
||||
vi.mock('../shared/Toast', () => ({
|
||||
@@ -157,8 +156,6 @@ function testButton(): HTMLElement {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
stubSettings();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useDawarichConnection } from '../../hooks/useDawarichConnection'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, StatusPill } from './settingsKit'
|
||||
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
@@ -30,16 +30,16 @@ export default function DawarichConnectionSection(): React.ReactElement {
|
||||
return (
|
||||
<Section
|
||||
id="dawarich"
|
||||
collapsible
|
||||
title={t('dawarich.title')}
|
||||
hint={t('dawarich.intro')}
|
||||
icon={DawarichIcon}
|
||||
iconFill
|
||||
badge={
|
||||
<StatusPill tone={S.connected ? 'success' : 'neutral'} icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>
|
||||
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
|
||||
</StatusPill>
|
||||
}
|
||||
>
|
||||
<SettingsHint>{t('dawarich.intro')}</SettingsHint>
|
||||
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('dawarich.url')} htmlFor="dawarich-url">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-057
|
||||
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -57,20 +57,20 @@ describe('DisplaySettingsTab', () => {
|
||||
expect(screen.getByText(/12h/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-015: clicking a language button calls updateSetting with that language code', async () => {
|
||||
it('FE-COMP-DISPLAY-015: picking a language in the select calls updateSetting with its code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('Deutsch'));
|
||||
await user.click(screen.getByRole('button', { name: 'Language' }));
|
||||
await user.click(await screen.findByText('Deutsch'));
|
||||
expect(updateSetting).toHaveBeenCalledWith('language', 'de');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
|
||||
it('FE-COMP-DISPLAY-016: the language select shows the current language', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
// "English" is on the chip and on the compact trigger; only the chip is a pressed choice.
|
||||
expect(screen.getByRole('button', { name: 'English', pressed: true })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Language' })).toHaveTextContent('English');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
|
||||
@@ -199,7 +199,7 @@ describe('DisplaySettingsTab – Display currency', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Same as the app' }));
|
||||
// The language grid shows the same name, so the option is the last one on screen.
|
||||
// The app language is German here, so the only English on screen is the place option.
|
||||
const options = await screen.findAllByText('English');
|
||||
await user.click(options[options.length - 1]);
|
||||
|
||||
@@ -232,85 +232,19 @@ describe('DisplaySettingsTab – Display currency', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Compact language dropdown (035–039) ───────────────────────────────────────
|
||||
// ── Language errors (037) ─────────────────────────────────────────────────────
|
||||
|
||||
function mobileLangWrap(): HTMLElement {
|
||||
return optionBlock('Language').querySelector('.sm\\:hidden') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('DisplaySettingsTab – Compact language picker', () => {
|
||||
it('FE-COMP-DISPLAY-035: the compact trigger shows the active language and opens the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DisplaySettingsTab />);
|
||||
const wrap = mobileLangWrap();
|
||||
|
||||
expect(within(wrap).getAllByRole('button')).toHaveLength(1);
|
||||
expect(within(wrap).getByText('English')).toBeInTheDocument();
|
||||
|
||||
await user.click(within(wrap).getByRole('button'));
|
||||
|
||||
expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-036: picking a language from the list saves it and closes the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
const wrap = mobileLangWrap();
|
||||
|
||||
await user.click(within(wrap).getByRole('button'));
|
||||
await user.click(within(wrap).getByText('Deutsch').closest('button')!);
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('language', 'de');
|
||||
expect(within(wrap).getAllByRole('button')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-037: a rejected pick from the list surfaces the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedFailing('Language locked');
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
const wrap = mobileLangWrap();
|
||||
|
||||
await user.click(within(wrap).getByRole('button'));
|
||||
await user.click(within(wrap).getByText('Deutsch').closest('button')!);
|
||||
|
||||
expect(await screen.findByText('Language locked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-048: a rejected pick from the desktop grid surfaces the error', async () => {
|
||||
describe('DisplaySettingsTab – Language select', () => {
|
||||
it('FE-COMP-DISPLAY-037: a rejected language pick surfaces the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedFailing('Language locked');
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
|
||||
await user.click(screen.getByText('Deutsch'));
|
||||
await user.click(screen.getByRole('button', { name: 'Language' }));
|
||||
await user.click(await screen.findByText('Deutsch'));
|
||||
|
||||
expect(await screen.findByText('Language locked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-038: a mousedown outside the picker closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DisplaySettingsTab />);
|
||||
const wrap = mobileLangWrap();
|
||||
|
||||
await user.click(within(wrap).getByRole('button'));
|
||||
expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1);
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
|
||||
expect(within(wrap).getAllByRole('button')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-039: a mousedown inside the picker keeps it open', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DisplaySettingsTab />);
|
||||
const wrap = mobileLangWrap();
|
||||
|
||||
await user.click(within(wrap).getByRole('button'));
|
||||
fireEvent.mouseDown(within(wrap).getByText('Deutsch'));
|
||||
|
||||
expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Map & privacy toggles (040–047) ───────────────────────────────────────────
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Languages, Map, ChevronDown, Rocket } from 'lucide-react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Languages, Map, Rocket } from 'lucide-react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||
import { DEFAULT_SETTINGS } from '../../store/settingsStore'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Segmented } from '../shared/dialogParts'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { POPOVER } from '../Packing/packingPopoverStyles'
|
||||
import { PopoverItem } from '../Packing/PackingPopover'
|
||||
import { preferredNavAppOptions } from '../Planner/placeNavigation'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { ChoiceChips, SettingRow, SettingRows } from './settingsKit'
|
||||
import { SettingRow, SettingRows, SettingsColumns } from './settingsKit'
|
||||
import { useSettingSaver } from './useSettingSaver'
|
||||
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
|
||||
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
|
||||
@@ -31,18 +28,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const demoLocked = useDemoAccountLock()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
|
||||
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
const langDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [langOpen])
|
||||
|
||||
useEffect(() => {
|
||||
setTempUnit(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
|
||||
}, [settings.temperature_unit])
|
||||
@@ -55,12 +40,12 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
|
||||
const startPage = settings.start_page === 'active_trip' ? 'active_trip' : DEFAULT_START_PAGE
|
||||
const startTripTab = settings.start_trip_tab || DEFAULT_START_TRIP_TAB
|
||||
const currentLanguage = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
|
||||
|
||||
const pickLanguage = (value: string) => save('language', value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsColumns
|
||||
left={<>
|
||||
<Section title={t('settings.general.startup')} icon={Rocket}>
|
||||
<SettingRows>
|
||||
{/* Where opening TREK lands */}
|
||||
@@ -127,47 +112,23 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Language: every option at a glance on a wide window, a compact list on a narrow one.
|
||||
The shared demo account always runs in English. */}
|
||||
{/* Language: a select like every other choice in this card. The shared
|
||||
demo account always runs in English. */}
|
||||
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
|
||||
<SettingRow label={t('settings.language')} stacked>
|
||||
<div className="hidden sm:block">
|
||||
<ChoiceChips
|
||||
label={t('settings.language')}
|
||||
value={settings.language}
|
||||
options={SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label }))}
|
||||
onChange={pickLanguage}
|
||||
/>
|
||||
</div>
|
||||
<div ref={langDropdownRef} className="relative sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLangOpen(v => !v)}
|
||||
aria-expanded={langOpen}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-[10px] border border-edge bg-surface-input px-3 py-2 text-start font-medium text-content"
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<span className="min-w-0 truncate">{currentLanguage?.label}</span>
|
||||
<ChevronDown size={14} className={`flex-none text-content-faint transition-transform ${langOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{langOpen && (
|
||||
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-50 max-h-[280px] overflow-y-auto" style={POPOVER}>
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<PopoverItem
|
||||
key={opt.value}
|
||||
icon={null}
|
||||
label={opt.label}
|
||||
active={settings.language === opt.value}
|
||||
onClick={async () => {
|
||||
setLangOpen(false)
|
||||
await pickLanguage(opt.value)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.language')}
|
||||
control={
|
||||
<div className={SELECT_BOX}>
|
||||
<CustomSelect
|
||||
value={settings.language || SUPPORTED_LANGUAGES[0].value}
|
||||
onChange={v => void pickLanguage(String(v))}
|
||||
options={SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label }))}
|
||||
searchable
|
||||
ariaLabel={t('settings.language')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DemoLock>
|
||||
|
||||
{/* Place names (#1799): searches can answer in another language than the app. */}
|
||||
@@ -286,6 +247,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
</SettingRows>
|
||||
</Section>
|
||||
|
||||
</>}
|
||||
right={
|
||||
<Section title={t('settings.general.travelMap')} icon={Map}>
|
||||
<SettingRows>
|
||||
{/* Preferred map app (#2423): opt-in, the picker stays the default */}
|
||||
@@ -327,7 +290,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
<OnOffSetting label={t('settings.optimizeFromAccommodation')} hint={t('settings.optimizeFromAccommodationHint')} on={settings.optimize_from_accommodation !== false} onChange={value => saveOnOff('optimize_from_accommodation', value)} />
|
||||
</SettingRows>
|
||||
</Section>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import IntegrationsTab from './IntegrationsTab';
|
||||
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
|
||||
|
||||
function enableMcp() {
|
||||
seedStore(useAddonStore, {
|
||||
@@ -32,6 +31,11 @@ function mcpEndpointRow() {
|
||||
return { code: row.querySelector('code')!, copy: row.querySelector('button')! };
|
||||
}
|
||||
|
||||
/** Waits for the MCP card: its endpoint, clients and tokens are on the page, nothing to open. */
|
||||
async function openMcp() {
|
||||
await screen.findByText('MCP Endpoint');
|
||||
}
|
||||
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
function enableAddons(ids: string[]) {
|
||||
@@ -51,8 +55,6 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
clipboardWriteText.mockClear();
|
||||
resetAllStores();
|
||||
useAssistantStore.setState({ status: null });
|
||||
@@ -91,21 +93,18 @@ describe('IntegrationsTab', () => {
|
||||
it('FE-COMP-INTEGRATIONS-004: MCP endpoint URL is displayed', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
const { code } = mcpEndpointRow();
|
||||
expect(code).not.toBeNull();
|
||||
expect(code.textContent).toContain('/mcp');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered when expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
it('FE-COMP-INTEGRATIONS-005: JSON config block is always rendered', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
// Config is collapsed by default — no <pre> yet
|
||||
expect(document.querySelector('pre')).toBeNull();
|
||||
// Expand by clicking the "Client Configuration" toggle
|
||||
await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
|
||||
await openMcp();
|
||||
// The client config is always shown: nothing folds away in the settings.
|
||||
expect(screen.getByText('Client Configuration')).toBeInTheDocument();
|
||||
const preEl = document.querySelector('pre');
|
||||
expect(preEl).not.toBeNull();
|
||||
expect(preEl!.textContent).toContain('mcpServers');
|
||||
@@ -115,7 +114,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
expect(await screen.findByText('No tokens yet. Create one to connect MCP clients.')).toBeInTheDocument();
|
||||
});
|
||||
@@ -134,7 +133,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('My Token');
|
||||
expect(await screen.findByText('Other Token')).toBeInTheDocument();
|
||||
@@ -144,7 +143,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
const createBtn = screen.getByRole('button', { name: /Create New Token/i });
|
||||
await user.click(createBtn);
|
||||
@@ -155,7 +154,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
@@ -167,7 +166,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
@@ -194,7 +193,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
@@ -224,7 +223,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
@@ -250,7 +249,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByLabelText('Delete Token'));
|
||||
@@ -276,7 +275,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByLabelText('Delete Token'));
|
||||
@@ -294,7 +293,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
// Spy after userEvent.setup() may have replaced navigator.clipboard
|
||||
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
await user.click(mcpEndpointRow().copy);
|
||||
@@ -305,7 +304,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
const { copy } = mcpEndpointRow();
|
||||
await user.click(copy);
|
||||
@@ -334,7 +333,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Cancel Token');
|
||||
await user.click(screen.getByLabelText('Delete Token'));
|
||||
@@ -365,7 +364,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
@@ -380,7 +379,7 @@ describe('IntegrationsTab', () => {
|
||||
it('FE-COMP-INTEGRATIONS-019: default tab is OAuth 2.1 Clients — OAuth hint visible, token list hidden', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
// OAuth hint is visible on the default tab
|
||||
expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
|
||||
// API Tokens "no tokens" message is not rendered
|
||||
@@ -391,7 +390,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
// Default: OAuth hint visible, token list absent
|
||||
expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
|
||||
@@ -475,7 +474,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
expect(screen.getByText('Claude.ai')).toBeInTheDocument();
|
||||
@@ -486,7 +485,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
// Presets render as buttons — click "Claude.ai" preset
|
||||
@@ -516,7 +515,7 @@ describe('IntegrationsTab', () => {
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
|
||||
@@ -550,7 +549,7 @@ describe('IntegrationsTab', () => {
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'TC2');
|
||||
@@ -646,7 +645,7 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
const createBtn = screen.getByRole('button', { name: /Register Client/i });
|
||||
@@ -665,7 +664,7 @@ describe('IntegrationsTab', () => {
|
||||
);
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
|
||||
@@ -680,7 +679,7 @@ describe('IntegrationsTab', () => {
|
||||
server.use(http.post('/api/oauth/clients', () => HttpResponse.error()));
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
|
||||
@@ -710,7 +709,7 @@ const oneToken = [
|
||||
];
|
||||
|
||||
async function openTokensTab(user: ReturnType<typeof userEvent.setup>) {
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
}
|
||||
|
||||
@@ -725,10 +724,9 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
|
||||
await user.click(labelledCopyButton());
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('mcp'));
|
||||
@@ -742,7 +740,6 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
await openTokensTab(user);
|
||||
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
|
||||
await user.click(labelledCopyButton());
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('mcp'));
|
||||
@@ -790,7 +787,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
@@ -803,7 +800,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
|
||||
@@ -834,7 +831,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await openMcp();
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Copyable');
|
||||
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
|
||||
@@ -1015,6 +1012,7 @@ describe('IntegrationsTab – failure toasts', () => {
|
||||
|
||||
enableAddons(['ai_assistant']);
|
||||
const { unmount } = render(<IntegrationsTab />);
|
||||
// Its own card on the page, the connection form open in it.
|
||||
expect(await screen.findByText('What would you like to chat with?')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useId } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
|
||||
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import MailImportSection from './MailImportSection'
|
||||
@@ -16,7 +16,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsColumns, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
|
||||
import { useIntegrationGates } from './useIntegrationGates'
|
||||
import WandererConnectionSection from './WandererConnectionSection'
|
||||
@@ -29,47 +29,43 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const demoLocked = useDemoAccountLock()
|
||||
return (
|
||||
<>
|
||||
{/* Two columns from xl up, as on the admin settings: what connects your
|
||||
trips with other services on the left, AI and developer access on the
|
||||
right. Flex, not a grid: a column with nothing to show (a managed
|
||||
install without the mail import) is empty and drops out, and the other
|
||||
takes the whole width instead of leaving a hole. The cards carry their
|
||||
own bottom margin, so only the gap between the columns is set. */}
|
||||
<div className="xl:flex xl:items-start xl:gap-5">
|
||||
<div className="min-w-0 flex-1 empty:hidden">
|
||||
{/* Forwarded booking mails: hides itself where this server or plan has no import. */}
|
||||
<MailImportSection />
|
||||
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
|
||||
runs themselves. On a managed install there is none, and every one of
|
||||
these would ask for an address that cannot be reached from here. */}
|
||||
{!managed && <PhotoProvidersSection />}
|
||||
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
|
||||
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
|
||||
{S.wandererEnabled && !managed && <WandererConnectionSection />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 empty:hidden">
|
||||
{/* Which model reads a booking, and what that costs, comes with the instance on
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
{/* The assistant in TREK, with the same managed rule as AI parsing: there the
|
||||
operator decides which model the install talks to. */}
|
||||
{S.aiConnectionEnabled && !managed && (
|
||||
<AiConnectionSection assistant={S.aiAssistantEnabled} parsing={S.llmEnabled} />
|
||||
)}
|
||||
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
|
||||
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
|
||||
one should not have to read past a section about AI assistants. */}
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
|
||||
<ApiKeysSection />
|
||||
</DemoLock>
|
||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||
{S.mcpEnabled && (
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
|
||||
<IntegrationsMcpSection S={S} />
|
||||
{/* What connects your trips with other services on the left, AI and
|
||||
developer access on the right, every card open on the page. */}
|
||||
<SettingsColumns
|
||||
left={
|
||||
<>
|
||||
{/* Forwarded booking mails: hides itself where this server or plan has no import. */}
|
||||
<MailImportSection />
|
||||
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
|
||||
runs themselves. On a managed install there is none, and every one of
|
||||
these would ask for an address that cannot be reached from here. */}
|
||||
{!managed && <PhotoProvidersSection />}
|
||||
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
|
||||
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
|
||||
{S.wandererEnabled && !managed && <WandererConnectionSection />}
|
||||
{/* The assistant and AI parsing: on a managed install the operator decides
|
||||
which model the install talks to, so neither is offered there. */}
|
||||
{S.aiConnectionEnabled && !managed && (
|
||||
<AiConnectionSection assistant={S.aiAssistantEnabled} parsing={S.llmEnabled} />
|
||||
)}
|
||||
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<>
|
||||
{/* An API key needs no addon, so it leads, ahead of the MCP row. */}
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
|
||||
<ApiKeysSection />
|
||||
</DemoLock>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||
{S.mcpEnabled && (
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
|
||||
<IntegrationsMcpSection S={S} />
|
||||
</DemoLock>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<McpTokenModals S={S} />
|
||||
<OAuthClientModals S={S} />
|
||||
</>
|
||||
@@ -151,10 +147,8 @@ function EmptyHint({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The mcp-remote snippet, folded away until asked for. */
|
||||
function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
/** The mcp-remote snippet, shown with its copy button. */
|
||||
function ConfigBlock({ json, hint, copied, onCopy, t }: {
|
||||
json: string
|
||||
hint: string
|
||||
copied: boolean
|
||||
@@ -163,33 +157,26 @@ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
<button type="button" onClick={onToggle} aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 px-3.5 py-2.5 text-start hover:bg-surface-secondary">
|
||||
<ChevronRight size={15} strokeWidth={2.2} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<div className="flex items-center gap-2 px-3.5 py-2.5">
|
||||
<span className="flex-1 font-medium text-content" style={fs(13, 'body')}>{t('settings.mcp.clientConfig')}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint p-3.5">
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={onCopy} className={`${SMALL_BUTTON} hover:text-content`} style={fs(12, 'body')}>
|
||||
{copied ? <Check size={13} strokeWidth={2.2} className="text-success" /> : <Copy size={13} strokeWidth={2} />}
|
||||
{copied ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 max-h-80 overflow-auto rounded-[10px] border border-edge-faint bg-surface-secondary p-3 font-mono leading-relaxed text-content" style={fs(11.5)}>
|
||||
{json}
|
||||
</pre>
|
||||
<SettingsHint>{hint}</SettingsHint>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" onClick={onCopy} className={`${SMALL_BUTTON} hover:text-content`} style={fs(12, 'body')}>
|
||||
{copied ? <Check size={13} strokeWidth={2.2} className="text-success" /> : <Copy size={13} strokeWidth={2} />}
|
||||
{copied ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint p-3.5">
|
||||
<pre className="m-0 max-h-80 overflow-auto rounded-[10px] border border-edge-faint bg-surface-secondary p-3 font-mono leading-relaxed text-content" style={fs(11.5)}>
|
||||
{json}
|
||||
</pre>
|
||||
<SettingsHint>{hint}</SettingsHint>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One registered OAuth client: what it is, the scopes it may use, and its two actions. */
|
||||
function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) {
|
||||
const { t, formatDate, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
|
||||
const expanded = !!oauthScopesExpanded[client.id]
|
||||
const { t, formatDate, setOauthRotateId, setOauthDeleteId } = S
|
||||
return (
|
||||
<div className={`${ROW} items-start`}>
|
||||
<span className={ROW_TILE}><KeyRound size={15} strokeWidth={2} /></span>
|
||||
@@ -205,17 +192,10 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1" style={fs(10.5)}>
|
||||
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
|
||||
{/* Every scope the client may use, none folded behind a "+N". */}
|
||||
{client.allowed_scopes.map(s => (
|
||||
<span key={s} className={CHIP}>{s}</span>
|
||||
))}
|
||||
{client.allowed_scopes.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
|
||||
className={`${CHIP} tabular-nums hover:text-content`}>
|
||||
{expanded ? '−' : `+${client.allowed_scopes.length - 5}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
@@ -233,13 +213,11 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
|
||||
/** The OAuth tab: the snippet, the registered clients and the sessions they hold. */
|
||||
function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
const {
|
||||
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
|
||||
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, copiedKey, mcpJsonConfigOAuth, handleCopy,
|
||||
} = S
|
||||
return (
|
||||
<>
|
||||
<ConfigBlock
|
||||
open={configOpenOAuth}
|
||||
onToggle={() => setConfigOpenOAuth(o => !o)}
|
||||
json={mcpJsonConfigOAuth}
|
||||
hint={t('settings.mcp.clientConfigHintOAuth')}
|
||||
copied={copiedKey === 'json-oauth'}
|
||||
@@ -251,7 +229,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SettingsHint className="min-w-0 flex-1 basis-64">{t('settings.oauth.clientsHint')}</SettingsHint>
|
||||
<button type="button" onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
|
||||
className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Plus size={14} strokeWidth={2.2} /> {t('settings.oauth.createClient')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -295,14 +273,12 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
|
||||
/** The deprecated API tokens tab: the warning, the snippet and the tokens. */
|
||||
function ApiTokensTab({ S }: { S: IntegrationsState }) {
|
||||
const { t, formatDate, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
|
||||
const { t, formatDate, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
|
||||
return (
|
||||
<>
|
||||
<WarningNote>{t('settings.mcp.apiTokensDeprecated')}</WarningNote>
|
||||
|
||||
<ConfigBlock
|
||||
open={configOpenToken}
|
||||
onToggle={() => setConfigOpenToken(o => !o)}
|
||||
json={mcpJsonConfig}
|
||||
hint={t('settings.mcp.clientConfigHint')}
|
||||
copied={copiedKey === 'json-token'}
|
||||
@@ -312,7 +288,7 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
className={SETTINGS_BUTTON}>
|
||||
<Plus size={14} strokeWidth={2.2} /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -352,13 +328,12 @@ function IntegrationsMcpSection({ S }: { S: IntegrationsState }) {
|
||||
return (
|
||||
<Section
|
||||
id="mcp"
|
||||
collapsible
|
||||
title={t('settings.mcp.title')}
|
||||
icon={Terminal}
|
||||
badge={
|
||||
connected > 0
|
||||
? <StatusPill tone="success" icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.mcp.appsConnected', { count: connected })}</StatusPill>
|
||||
: <StatusPill>{t('settings.notSetUp')}</StatusPill>
|
||||
: <StatusPill icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.notSetUp')}</StatusPill>
|
||||
}
|
||||
>
|
||||
{/* Endpoint URL */}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import type { Settings } from '../../types';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import LlmConnectionSection from './LlmConnectionSection';
|
||||
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
|
||||
|
||||
let loadSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -51,8 +50,6 @@ async function pickProvider(user: ReturnType<typeof userEvent.setup>, current: R
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedRole('user');
|
||||
|
||||
@@ -45,7 +45,6 @@ export default function LlmConnectionSection(): React.ReactElement {
|
||||
return (
|
||||
<Section
|
||||
id="ai-parsing"
|
||||
collapsible
|
||||
title={t('settings.aiParsing.title')}
|
||||
icon={Sparkles}
|
||||
badge={<ParsingState />}
|
||||
@@ -117,5 +116,5 @@ function ParsingState() {
|
||||
const model = useSettingsStore(s => s.settings.llm_model)
|
||||
return model
|
||||
? <StatusPill tone="success" icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{model}</StatusPill>
|
||||
: <StatusPill>{t('settings.notSetUp')}</StatusPill>
|
||||
: <StatusPill icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.notSetUp')}</StatusPill>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, Copy, FileUp, Inbox, Loader2, Mail, RefreshCw } from 'lucide-react';
|
||||
import { Copy, FileUp, Inbox, Loader2, Mail, RefreshCw } from 'lucide-react';
|
||||
import { useRef, type ReactElement } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { openMailInbox } from '../MailIngest/mailInboxOpener';
|
||||
@@ -31,7 +31,6 @@ export default function MailImportSection(): ReactElement | null {
|
||||
return (
|
||||
<Section
|
||||
id="mail-import"
|
||||
collapsible
|
||||
title={t('mailInbox.settings.title')}
|
||||
icon={Mail}
|
||||
badge={
|
||||
@@ -78,21 +77,14 @@ export default function MailImportSection(): ReactElement | null {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SettingsHint>{t('mailInbox.settings.addressHint')}</SettingsHint>
|
||||
<details className="group rounded-lg bg-surface-tertiary px-3 py-2 text-body text-content-secondary">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 font-medium text-content">
|
||||
<ChevronRight
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className="transition-transform group-open:rotate-90 rtl:-scale-x-100"
|
||||
/>
|
||||
{t('mailInbox.settings.howTo')}
|
||||
</summary>
|
||||
<div className="rounded-lg bg-surface-tertiary px-3 py-2 text-body text-content-secondary">
|
||||
<div className="font-medium text-content">{t('mailInbox.settings.howTo')}</div>
|
||||
<ul className="mt-2 flex list-disc flex-col gap-1.5 ps-5 text-caption">
|
||||
<li>{t('mailInbox.settings.howToGmail')}</li>
|
||||
<li>{t('mailInbox.settings.howToOutlook')}</li>
|
||||
<li>{t('mailInbox.settings.howToManual')}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -379,7 +379,6 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{saving ? <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" /> : <Save size={14} strokeWidth={2} />}
|
||||
{t('settings.saveMap')}
|
||||
|
||||
@@ -119,10 +119,10 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
aria-label={t('settings.webhookUrl.label')}
|
||||
className={`${INPUT} min-w-[220px] flex-1`}
|
||||
/>
|
||||
<button type="button" onClick={testWebhookUrl} disabled={webhookTestDisabled} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={testWebhookUrl} disabled={webhookTestDisabled} className={SETTINGS_BUTTON}>
|
||||
{t('settings.webhookUrl.test')}
|
||||
</button>
|
||||
<button type="button" onClick={saveWebhookUrl} disabled={webhookSaving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={saveWebhookUrl} disabled={webhookSaving} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -164,17 +164,17 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
className={`${INPUT} flex-1`}
|
||||
/>
|
||||
{ntfyTokenIsSet && (
|
||||
<button type="button" onClick={clearNtfyToken} className={SETTINGS_BUTTON_DANGER} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={clearNtfyToken} className={SETTINGS_BUTTON_DANGER}>
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</EditorField>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button type="button" onClick={testNtfySettings} disabled={!ntfyTopic || ntfyTesting} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={testNtfySettings} disabled={!ntfyTopic || ntfyTesting} className={SETTINGS_BUTTON}>
|
||||
{t('settings.ntfyUrl.test')}
|
||||
</button>
|
||||
<button type="button" onClick={saveNtfySettings} disabled={ntfySaving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={saveNtfySettings} disabled={ntfySaving} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -198,7 +198,7 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
{/* Unconfigured is the common case on first use — send them to where the
|
||||
credentials actually live rather than just naming the place. */}
|
||||
{!ch.configured && ch.settingsPath && (
|
||||
<Link to={ch.settingsPath} className={`${SETTINGS_BUTTON_PRIMARY} no-underline`} style={fs(13, 'body')}>
|
||||
<Link to={ch.settingsPath} className={`${SETTINGS_BUTTON_PRIMARY} no-underline`}>
|
||||
{t('settings.notificationPreferences.configure')}
|
||||
</Link>
|
||||
)}
|
||||
@@ -206,7 +206,6 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
onClick={() => testChannel(ch)}
|
||||
disabled={!ch.configured || channelTesting === ch.id}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{t('settings.notificationPreferences.sendTest')}
|
||||
</button>
|
||||
|
||||
@@ -14,7 +14,7 @@ import React, { useState } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SettingRow, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER } from './settingsKit'
|
||||
import { SettingRow, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsColumns } from './settingsKit'
|
||||
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning, offlineProgressLabel, offlineRowMeta } from './useOfflineSettings'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -49,6 +49,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Two columns by length: what the mode does and what is cached on the
|
||||
left, the per-trip choice of what to keep on the right. */}
|
||||
<SettingsColumns
|
||||
left={<>
|
||||
{/* Offline mode + prepare */}
|
||||
<Section title={t('settings.offline.mode.title')} icon={CloudOff}>
|
||||
<SettingRows>
|
||||
@@ -71,7 +75,6 @@ export default function OfflineTab(): React.ReactElement {
|
||||
onClick={runPrepare}
|
||||
disabled={preparing || offline}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{preparing
|
||||
? <RefreshCw size={14} className="animate-spin" />
|
||||
@@ -82,7 +85,6 @@ export default function OfflineTab(): React.ReactElement {
|
||||
onClick={handleResync}
|
||||
disabled={syncing || offline}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<RefreshCw size={14} className={syncing ? 'animate-spin' : undefined} />
|
||||
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
|
||||
@@ -130,10 +132,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
{t('settings.offline.conflicts.item', { name: conflictName(c) })}
|
||||
</span>
|
||||
<div className="flex flex-none gap-2">
|
||||
<button type="button" onClick={() => resolveConflict(c.id, true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={() => resolveConflict(c.id, true)} className={SETTINGS_BUTTON}>
|
||||
{t('settings.offline.conflicts.keepMine')}
|
||||
</button>
|
||||
<button type="button" onClick={() => resolveConflict(c.id, false)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={() => resolveConflict(c.id, false)} className={SETTINGS_BUTTON}>
|
||||
{t('settings.offline.conflicts.keepServer')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -163,41 +165,6 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* What to store offline */}
|
||||
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('settings.offline.storage.tiles')}
|
||||
hint={t('settings.offline.storage.tilesHint')}
|
||||
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
|
||||
/>
|
||||
</SettingRows>
|
||||
{allTrips.length > 0 && (
|
||||
<div>
|
||||
<p className={EYEBROW} style={fs(10)}>{t('settings.offline.storage.tripsTitle')}</p>
|
||||
<SettingRows>
|
||||
{allTrips.map((trip) => {
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
return (
|
||||
<div key={trip.id} className="flex items-center justify-between gap-4 px-3.5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{trip.title}
|
||||
</div>
|
||||
<div className={on ? 'text-success' : 'text-content-faint'} style={fs(11.5)}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
{!dateEligible && `. ${t('settings.offline.storage.tripFinished')}`}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Cache stats + list + clear */}
|
||||
<Section
|
||||
title={t('settings.offline.cache.title')}
|
||||
@@ -207,7 +174,6 @@ export default function OfflineTab(): React.ReactElement {
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={clearing || !canClear}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
@@ -225,11 +191,11 @@ export default function OfflineTab(): React.ReactElement {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<SettingsHint>{t('settings.offline.failed.hint')}</SettingsHint>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => void retryFailed()} disabled={offline} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={() => void retryFailed()} disabled={offline} className={SETTINGS_BUTTON}>
|
||||
<RefreshCw size={14} />
|
||||
{t('settings.offline.failed.retry')}
|
||||
</button>
|
||||
<button type="button" onClick={() => void discardFailed()} className={SETTINGS_BUTTON_DANGER} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={() => void discardFailed()} className={SETTINGS_BUTTON_DANGER}>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.failed.discard')}
|
||||
</button>
|
||||
@@ -268,6 +234,44 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</SettingRows>
|
||||
)}
|
||||
</Section>
|
||||
</>}
|
||||
right={<>
|
||||
{/* What to store offline */}
|
||||
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('settings.offline.storage.tiles')}
|
||||
hint={t('settings.offline.storage.tilesHint')}
|
||||
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
|
||||
/>
|
||||
</SettingRows>
|
||||
{allTrips.length > 0 && (
|
||||
<div>
|
||||
<p className={EYEBROW} style={fs(10)}>{t('settings.offline.storage.tripsTitle')}</p>
|
||||
<SettingRows>
|
||||
{allTrips.map((trip) => {
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
return (
|
||||
<div key={trip.id} className="flex items-center justify-between gap-4 px-3.5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{trip.title}
|
||||
</div>
|
||||
<div className={on ? 'text-success' : 'text-content-faint'} style={fs(11.5)}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
{!dateEligible && `. ${t('settings.offline.storage.tripFinished')}`}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</>}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmClear}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
|
||||
badge={pk.creds.length > 0 ? <StatusPill>{pk.creds.length}</StatusPill> : undefined}
|
||||
// The way in sits on the band, like the planner's "add" actions; the step-up opens in the body.
|
||||
action={pk.canAdd && !pk.addOpen ? (
|
||||
<button type="button" onClick={() => pk.setAddOpen(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={() => pk.setAddOpen(true)} className={SETTINGS_BUTTON}>
|
||||
<Plus size={14} />
|
||||
{t('settings.passkey.add')}
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import PhotoProvidersSection from './PhotoProvidersSection';
|
||||
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
|
||||
|
||||
const fakeProvider = {
|
||||
id: 'immich',
|
||||
@@ -46,8 +45,6 @@ function seedMemoriesEnabled(providers = [fakeProvider]) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
|
||||
@@ -36,7 +36,6 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
return (
|
||||
<Section
|
||||
id={`photos-${provider.id}`}
|
||||
collapsible
|
||||
key={provider.id}
|
||||
title={provider.name || provider.id}
|
||||
icon={Camera}
|
||||
|
||||
@@ -66,8 +66,8 @@ function PluginOAuthSection({ id, state, setState }: {
|
||||
? <span className="inline-flex items-center gap-1.5"><CheckCircle size={14} className="flex-none text-success" />{t('settings.plugins.oauth.connected')}</span>
|
||||
: t('settings.plugins.oauth.notConnected')}
|
||||
control={state.connected
|
||||
? <button type="button" onClick={disconnect} disabled={busy} className={SETTINGS_BUTTON} style={fs(13, 'body')}><Unlink size={14} />{t('settings.plugins.oauth.disconnect')}</button>
|
||||
: <button type="button" onClick={connect} disabled={busy} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>{busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} />}{t('settings.plugins.oauth.connect')}</button>}
|
||||
? <button type="button" onClick={disconnect} disabled={busy} className={SETTINGS_BUTTON}><Unlink size={14} />{t('settings.plugins.oauth.disconnect')}</button>
|
||||
: <button type="button" onClick={connect} disabled={busy} className={SETTINGS_BUTTON_PRIMARY}>{busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} />}{t('settings.plugins.oauth.connect')}</button>}
|
||||
/>
|
||||
</SettingRows>
|
||||
)
|
||||
@@ -158,7 +158,7 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
@@ -180,7 +180,6 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
onClick={() => runAction(a)}
|
||||
disabled={running !== null}
|
||||
className={a.danger ? SETTINGS_BUTTON_DANGER : SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{running === a.key && <Loader2 size={14} className="animate-spin" />}
|
||||
{a.label}
|
||||
|
||||
@@ -9,22 +9,24 @@ interface SectionProps {
|
||||
* standing in for a glyph gets sized by the same utilities.
|
||||
*/
|
||||
icon: LucideIcon | React.ComponentType<{ className?: string }>
|
||||
/** The icon is a brand badge that fills its tile instead of a glyph in it. */
|
||||
iconFill?: boolean
|
||||
badge?: React.ReactNode
|
||||
/** One line under the title in the head band. */
|
||||
hint?: React.ReactNode
|
||||
/** A control on the right of the head band. */
|
||||
action?: React.ReactNode
|
||||
/** Where a link lands and, for a collapsible card, what it is remembered by. */
|
||||
/** Where a link lands: `#id` scrolls to the card, or opens it from an overview. */
|
||||
id?: string
|
||||
/** Shut until its head is clicked, the badge saying its state meanwhile (see settingsKit). */
|
||||
collapsible?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** A settings group: the planner's card with a head band (see settingsKit). */
|
||||
export default function Section({ title, icon, badge, hint, action, id, collapsible, children }: SectionProps): React.ReactElement {
|
||||
/**
|
||||
* A settings group: the planner's card with a head band (see settingsKit).
|
||||
*/
|
||||
export default function Section({ title, icon, iconFill, badge, hint, action, id, children }: SectionProps): React.ReactElement {
|
||||
return (
|
||||
<SettingsCard icon={icon} title={title} badge={badge} hint={hint} action={action} id={id} collapsible={collapsible}>
|
||||
<SettingsCard icon={icon} iconFill={iconFill} title={title} badge={badge} hint={hint} action={action} id={id}>
|
||||
{children}
|
||||
</SettingsCard>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import WandererConnectionSection from './WandererConnectionSection';
|
||||
|
||||
@@ -32,8 +31,6 @@ function settingsRoute(state: { current: typeof disconnected }) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SettingRow,
|
||||
SettingRows,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from './settingsKit';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
@@ -32,8 +31,8 @@ export default function WandererConnectionSection(): React.ReactElement {
|
||||
return (
|
||||
<Section
|
||||
id="wanderer"
|
||||
collapsible
|
||||
title={t('wanderer.title')}
|
||||
hint={t('wanderer.intro')}
|
||||
icon={WandererMark}
|
||||
badge={
|
||||
<StatusPill
|
||||
@@ -44,7 +43,6 @@ export default function WandererConnectionSection(): React.ReactElement {
|
||||
</StatusPill>
|
||||
}
|
||||
>
|
||||
<SettingsHint>{t('wanderer.intro')}</SettingsHint>
|
||||
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('wanderer.url')} htmlFor="wanderer-url">
|
||||
|
||||
@@ -48,7 +48,6 @@ function HintsButton({ W }: { W: WandererWatchController }) {
|
||||
onClick={W.openHints}
|
||||
disabled={count === 0}
|
||||
className={`${SETTINGS_BUTTON} relative`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<BellRing size={13} strokeWidth={2.2} />
|
||||
{t('wanderer.watch.hints')}
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function WebPushCard(): React.ReactElement {
|
||||
onClick={push.sendTest}
|
||||
disabled={!push.canTest}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{t('settings.notificationPreferences.sendTest')}
|
||||
</button>
|
||||
@@ -41,7 +40,6 @@ export default function WebPushCard(): React.ReactElement {
|
||||
onClick={push.toggle}
|
||||
disabled={push.busy}
|
||||
className={push.on ? SETTINGS_BUTTON : SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{push.switchLabel}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AiApiKeyKind, AiConnectionScope, AiParsingPrefill } from '@trek/shared';
|
||||
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
|
||||
import { KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
@@ -193,7 +193,6 @@ function KeyForm({ scope, keyConnect }: { scope: AiConnectionScope; keyConnect:
|
||||
onClick={() => void submit()}
|
||||
disabled={!kind || testing}
|
||||
className={`${SETTINGS_BUTTON_PRIMARY} whitespace-nowrap`}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{testing && <Loader2 size={14} className="animate-spin" />}
|
||||
{testing ? t('assistant.connect.testing') : t('assistant.connect.save')}
|
||||
@@ -225,7 +224,6 @@ function KeyForm({ scope, keyConnect }: { scope: AiConnectionScope; keyConnect:
|
||||
onClick={() => void openrouter.start()}
|
||||
disabled={openrouter.busy}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{openrouter.busy ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
@@ -298,7 +296,6 @@ function CustomForm({ custom }: { custom: ReturnType<typeof useCustomConnect> })
|
||||
onClick={() => void custom.submit()}
|
||||
disabled={!custom.ready || custom.saving}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{custom.saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{custom.saving ? t('assistant.connect.testing') : t('assistant.connect.save')}
|
||||
@@ -314,7 +311,6 @@ function CustomForm({ custom }: { custom: ReturnType<typeof useCustomConnect> })
|
||||
*/
|
||||
function PrivacyNote({ provider }: { provider: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="rounded-[12px] bg-surface-tertiary px-3 py-2.5">
|
||||
{provider && (
|
||||
@@ -322,28 +318,16 @@ function PrivacyNote({ provider }: { provider: string }) {
|
||||
{t('assistant.privacy.banner', { provider })}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="inline-flex items-center gap-1.5 font-medium text-content-muted hover:text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{/* Said in full before anything is saved, not folded behind a link. */}
|
||||
<div className="inline-flex items-center gap-1.5 font-medium text-content-muted" style={fs(12, 'body')}>
|
||||
<ShieldCheck size={13} strokeWidth={2.2} />
|
||||
{t('assistant.privacy.what')}
|
||||
<ChevronRight
|
||||
size={13}
|
||||
strokeWidth={2.2}
|
||||
className={`transition-transform ${open ? 'rotate-90' : 'rtl:rotate-180'}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="m-0 mt-2 flex list-disc flex-col gap-1 ps-5 text-content-faint" style={fs(11.5)}>
|
||||
<li>{t('assistant.privacy.shared')}</li>
|
||||
<li>{t('assistant.privacy.notShared')}</li>
|
||||
{provider && <li>{t('assistant.privacy.provider', { provider })}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<ul className="m-0 mt-2 flex list-disc flex-col gap-1 ps-5 text-content-faint" style={fs(11.5)}>
|
||||
<li>{t('assistant.privacy.shared')}</li>
|
||||
<li>{t('assistant.privacy.notShared')}</li>
|
||||
{provider && <li>{t('assistant.privacy.provider', { provider })}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { AiStatusResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { openSettingsCards } from '../../../../tests/helpers/settingsCards';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
@@ -31,8 +30,6 @@ const INSTANCE: AiStatusResponse = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAdmin, buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { openSettingsCards } from '../../../../tests/helpers/settingsCards';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
@@ -49,8 +48,6 @@ function status(over: Partial<AiStatusResponse> = {}): AiStatusResponse {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
|
||||
@@ -65,7 +65,7 @@ function OpenAssistantButton({ S }: { S: AiConnections }) {
|
||||
const { t } = useTranslation();
|
||||
if (!S.canOpen) return null;
|
||||
return (
|
||||
<button type="button" onClick={S.openAssistant} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={S.openAssistant} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<MessageCircle size={14} strokeWidth={2.2} /> {t('assistant.connect.openAssistant')}
|
||||
</button>
|
||||
);
|
||||
@@ -85,7 +85,6 @@ function OwnConnection({ S, own }: { S: AiConnections; own: AiConnectionView })
|
||||
type="button"
|
||||
onClick={() => S.repair(repairMode)}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{t(repairMode === 'chatgpt' ? 'assistant.errorAction.signIn' : 'assistant.errorAction.replaceKey')}
|
||||
</button>
|
||||
@@ -98,7 +97,6 @@ function OwnConnection({ S, own }: { S: AiConnections; own: AiConnectionView })
|
||||
onClick={() => void signOut()}
|
||||
disabled={busy}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<LogOut size={14} strokeWidth={2} /> {t('assistant.connect.signOut')}
|
||||
</button>
|
||||
@@ -108,7 +106,6 @@ function OwnConnection({ S, own }: { S: AiConnections; own: AiConnectionView })
|
||||
onClick={askDisconnect}
|
||||
disabled={busy}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<Unplug size={14} strokeWidth={2} /> {t('assistant.connect.disconnect')}
|
||||
</button>
|
||||
@@ -157,7 +154,7 @@ function InstanceConnection({ S }: { S: AiConnections }) {
|
||||
>
|
||||
<OpenAssistantButton S={S} />
|
||||
{S.status?.canConnectOwn && !S.panel && (
|
||||
<button type="button" onClick={S.connectOwn} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={S.connectOwn} className={SETTINGS_BUTTON}>
|
||||
{t('assistant.errorAction.connectOwn')}
|
||||
</button>
|
||||
)}
|
||||
@@ -213,7 +210,7 @@ function LockedOwnConnection() {
|
||||
<SettingRow
|
||||
label={t('demo.lock.aiTitle')}
|
||||
control={
|
||||
<button type="button" disabled className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
<button type="button" disabled className={SETTINGS_BUTTON}>
|
||||
{t('assistant.errorAction.connectOwn')}
|
||||
</button>
|
||||
}
|
||||
@@ -230,7 +227,7 @@ function ConnectPanel({ S }: { S: AiConnections }) {
|
||||
<div className={`flex flex-col gap-3 ${S.view === 'setup' ? '' : 'border-t border-edge-faint pt-4'}`}>
|
||||
<AiConnectPanel key={S.panelMode ?? 'any'} scope="user" onConnected={S.onConnected} initialMode={S.panelMode} />
|
||||
{S.view !== 'setup' && (
|
||||
<button type="button" onClick={S.closePanel} className={`${SETTINGS_BUTTON} self-start`} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={S.closePanel} className={`${SETTINGS_BUTTON} self-start`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
@@ -283,7 +280,6 @@ export default function AiConnectionSection({
|
||||
return (
|
||||
<Section
|
||||
id="ai-connection"
|
||||
collapsible
|
||||
title={t(text.titleKey)}
|
||||
icon={Sparkles}
|
||||
hint={t(text.hintKey)}
|
||||
|
||||
@@ -113,7 +113,6 @@ export default function ChatGptPasteStep({
|
||||
}}
|
||||
aria-disabled={!flow}
|
||||
className={`${SETTINGS_BUTTON_PRIMARY} ${flow ? '' : 'pointer-events-none opacity-50'}`}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{flow ? <ExternalLink size={14} strokeWidth={2.2} /> : <Loader2 size={14} className="animate-spin" />}
|
||||
{t('assistant.connect.chatgptOpen')}
|
||||
@@ -163,7 +162,6 @@ export default function ChatGptPasteStep({
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || !flow}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('assistant.connect.save')}
|
||||
@@ -174,7 +172,6 @@ export default function ChatGptPasteStep({
|
||||
onClick={pasteFromClipboard}
|
||||
disabled={!flow}
|
||||
className={`${SETTINGS_BUTTON} whitespace-nowrap`}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<ClipboardPaste size={14} strokeWidth={2} />
|
||||
{t('assistant.connect.chatgptPaste')}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// FE-SETTINGS-KIT-001 to FE-SETTINGS-KIT-004
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SettingsCard, StatusPill, settingsCardOpenKey } from './settingsKit';
|
||||
|
||||
function card(props: { collapsible?: boolean; id?: string } = {}) {
|
||||
return (
|
||||
<SettingsCard
|
||||
title="AirTrail"
|
||||
badge={<StatusPill>Connected</StatusPill>}
|
||||
action={<button type="button">Sync</button>}
|
||||
{...props}
|
||||
>
|
||||
<label>
|
||||
Address
|
||||
<input />
|
||||
</label>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
describe('SettingsCard', () => {
|
||||
it('FE-SETTINGS-KIT-001: a collapsible card starts shut, its state still showing in the head', () => {
|
||||
render(card({ collapsible: true, id: 'airtrail' }));
|
||||
const head = screen.getByRole('button', { name: 'AirTrail' });
|
||||
expect(head).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByText('Connected')).toBeVisible();
|
||||
expect(screen.queryByRole('textbox', { name: 'Address' })).toBeNull();
|
||||
// The head's own action stays a button of its own.
|
||||
expect(screen.getByRole('button', { name: 'Sync' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('FE-SETTINGS-KIT-002: the head opens and shuts it, and the browser remembers it open', () => {
|
||||
const { unmount } = render(card({ collapsible: true, id: 'airtrail' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AirTrail' }));
|
||||
expect(screen.getByRole('button', { name: 'AirTrail' })).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByRole('textbox', { name: 'Address' })).toBeVisible();
|
||||
expect(localStorage.getItem(settingsCardOpenKey('airtrail'))).toBe('1');
|
||||
|
||||
unmount();
|
||||
render(card({ collapsible: true, id: 'airtrail' }));
|
||||
expect(screen.getByRole('textbox', { name: 'Address' })).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AirTrail' }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Address' })).toBeNull();
|
||||
expect(localStorage.getItem(settingsCardOpenKey('airtrail'))).toBe('0');
|
||||
});
|
||||
|
||||
it('FE-SETTINGS-KIT-003: a link to the card opens it', () => {
|
||||
window.location.hash = '#airtrail';
|
||||
render(card({ collapsible: true, id: 'airtrail' }));
|
||||
expect(screen.getByRole('textbox', { name: 'Address' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('FE-SETTINGS-KIT-004: a card that is not collapsible has no toggle and shows its body', () => {
|
||||
render(card());
|
||||
expect(screen.queryByRole('button', { name: 'AirTrail' })).toBeNull();
|
||||
expect(screen.getByRole('heading', { name: 'AirTrail' })).toBeVisible();
|
||||
expect(screen.getByRole('textbox', { name: 'Address' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { ChevronDown, type LucideIcon } from 'lucide-react'
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { Info, type LucideIcon } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
|
||||
/*
|
||||
@@ -12,6 +13,29 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
|
||||
type IconLike = LucideIcon | React.ComponentType<{ className?: string; size?: number; strokeWidth?: number }>
|
||||
|
||||
/**
|
||||
* A card's or a row's icon in its framed tile. A lucide glyph sits in the middle
|
||||
* at glyph size; a brand mark that is itself a badge (`fill`, as Dawarich's and
|
||||
* AirTrail's are) fills the tile corner to corner, the way the addon manager
|
||||
* shows them, instead of floating small inside a second frame.
|
||||
*/
|
||||
function IconTile({ icon: Icon, fill = false, tone = 'neutral' }: {
|
||||
icon: IconLike
|
||||
fill?: boolean
|
||||
tone?: 'neutral' | 'danger'
|
||||
}) {
|
||||
const box = 'h-8 w-8 rounded-[10px]'
|
||||
if (fill) {
|
||||
const Mark = Icon as React.ComponentType<{ fill?: boolean }>
|
||||
return <span className={`block flex-none overflow-hidden shadow-sm ${box}`}><Mark fill /></span>
|
||||
}
|
||||
return (
|
||||
<span className={`grid flex-none place-items-center bg-surface-card shadow-sm ${box} ${tone === 'danger' ? 'text-danger' : 'text-content-secondary'}`}>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The page's own bar: icon tile, title and subtitle on the left, actions on the right. */
|
||||
export function SettingsHeader({ icon: Icon, title, subtitle, actions }: {
|
||||
icon: IconLike
|
||||
@@ -33,56 +57,89 @@ export function SettingsHeader({ icon: Icon, title, subtitle, actions }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Where a collapsible card remembers that its reader left it open. */
|
||||
export const settingsCardOpenKey = (id: string) => `trek-settings-open-${id}`
|
||||
/** True inside a card's body: a card there is drawn as a block of it (see SettingsCard). */
|
||||
const CardBodyContext = createContext(false)
|
||||
|
||||
|
||||
/**
|
||||
* Whether a collapsible card is open. Shut by default, so a page lists what it
|
||||
* holds instead of everything at once; open when the address names it (a link
|
||||
* to `#id` lands on it ready to use), or when its reader left it open last time.
|
||||
* The browser remembers that per card as a convenience: without storage (a
|
||||
* private window, blocked site data) every card simply starts shut.
|
||||
* One part of a settings card: a rounded white block with a small head row
|
||||
* (title, a pill, an action) and its content under a hairline. Every part of a
|
||||
* card is one of these, so a card reads as separate, labelled pieces.
|
||||
*/
|
||||
export function useCardOpen(id: string | undefined, collapsible: boolean): [boolean, () => void] {
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (!collapsible) return true
|
||||
if (id && window.location.hash === `#${id}`) return true
|
||||
if (!id) return false
|
||||
try {
|
||||
return localStorage.getItem(settingsCardOpenKey(id)) === '1'
|
||||
} catch {
|
||||
// Storage refused: the card starts shut, as it would on a first visit.
|
||||
return false
|
||||
}
|
||||
})
|
||||
const toggle = () => {
|
||||
const next = !open
|
||||
setOpen(next)
|
||||
if (!id) return
|
||||
try {
|
||||
localStorage.setItem(settingsCardOpenKey(id), next ? '1' : '0')
|
||||
} catch {
|
||||
// Storage refused: the card still opens, it is only not remembered.
|
||||
}
|
||||
}
|
||||
return [!collapsible || open, toggle]
|
||||
export function SettingsBlock({ id, title, hint, badge, action, tone = 'neutral', flush = false, children }: {
|
||||
id?: string
|
||||
/** The content runs to the block's edges: for a list of rows, which bring their own padding. */
|
||||
flush?: boolean
|
||||
title: ReactNode
|
||||
hint?: ReactNode
|
||||
badge?: ReactNode
|
||||
action?: ReactNode
|
||||
tone?: 'neutral' | 'danger'
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* A tinted head row, so a block's title reads as its heading. */}
|
||||
<div className="flex min-w-0 items-center gap-2 bg-surface-secondary px-3.5 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className={`m-0 truncate font-semibold ${tone === 'danger' ? 'text-danger' : 'text-content'}`} style={fs(13, 'body')}>{title}</h3>
|
||||
{badge}
|
||||
</div>
|
||||
{hint && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
</div>
|
||||
{action && <div className="flex flex-none items-center gap-1.5">{action}</div>}
|
||||
</div>
|
||||
{children != null && (
|
||||
// Inside the block its own rows and fields: a card in it is not nested again.
|
||||
// Its hairline goes with it, so a block whose content renders nothing is its head alone.
|
||||
<div className={`flex flex-col border-t border-edge-faint empty:hidden ${flush ? 'divide-y divide-edge-faint' : 'gap-3 p-3.5'}`}>
|
||||
<CardBodyContext.Provider value={false}>{children}</CardBodyContext.Provider>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's head band: icon tile, title with its badge, a short hint under it,
|
||||
* an action on the right. The same over a card of fields and over a list of
|
||||
* rows, so every group on a settings page starts the same way.
|
||||
*/
|
||||
function CardHead({ icon: Icon, iconFill = false, title, hint, badge, action, tone = 'neutral' }: {
|
||||
icon?: IconLike
|
||||
iconFill?: boolean
|
||||
title: ReactNode
|
||||
hint?: ReactNode
|
||||
badge?: ReactNode
|
||||
action?: ReactNode
|
||||
tone?: 'neutral' | 'danger'
|
||||
}) {
|
||||
const tint = tone === 'danger' ? 'color-mix(in srgb, var(--danger) 9%, transparent)' : NEUTRAL_TINT
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-edge-faint px-4 py-3" style={{ background: tint }}>
|
||||
{Icon && <IconTile icon={Icon} fill={iconFill} tone={tone} />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 truncate font-bold text-content" style={fs(14, 'body')}>{title}</h2>
|
||||
{badge}
|
||||
</div>
|
||||
{hint && <p className="m-0 mt-0.5 text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
</div>
|
||||
{action && <div className="flex flex-none items-center gap-1.5">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of settings: a card with a head band (icon tile, title, an optional
|
||||
* badge and an action on the right) and its body below. `tone` tints the band:
|
||||
* neutral for ordinary groups, danger for the destructive ones.
|
||||
*
|
||||
* `collapsible` shuts the body until the head is clicked. The badge is then the
|
||||
* card's state at a glance ("Connected", "Off"), so a shut card still says what
|
||||
* matters. The title holds the button (a heading inside a button is not valid
|
||||
* HTML), stretched over the whole head; the badge and the action sit above it
|
||||
* and stay clickable on their own. The body stays mounted while shut, so
|
||||
* nothing typed into it is lost. A collapsible card needs an `id` to be linked
|
||||
* to and remembered.
|
||||
*/
|
||||
export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName, flush = false, collapsible = false }: {
|
||||
export function SettingsCard({ icon: Icon, iconFill = false, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName, flush = false }: {
|
||||
icon?: IconLike
|
||||
/** The icon is a brand badge that fills its tile (see IconTile). */
|
||||
iconFill?: boolean
|
||||
title: ReactNode
|
||||
/** One line under the title in the band. */
|
||||
hint?: ReactNode
|
||||
@@ -95,68 +152,47 @@ export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'n
|
||||
bodyClassName?: string
|
||||
/** Inside another card: no gap below, the parent spaces its children. */
|
||||
flush?: boolean
|
||||
/** Shut until its head is clicked; see above. */
|
||||
collapsible?: boolean
|
||||
}) {
|
||||
const tint = tone === 'danger' ? 'color-mix(in srgb, var(--danger) 9%, transparent)' : NEUTRAL_TINT
|
||||
const [open, toggle] = useCardOpen(id, collapsible)
|
||||
const bodyId = useId()
|
||||
const hasBody = children != null
|
||||
const ref = useRef<HTMLElement>(null)
|
||||
// A link to `#id` opened this card; bring it into view, as the router does not scroll to an anchor.
|
||||
useEffect(() => {
|
||||
if (id && window.location.hash === `#${id}`) ref.current?.scrollIntoView?.({ block: 'start' })
|
||||
}, [id])
|
||||
// Inside another card's body a card is a block of it, never a card in a card:
|
||||
// the rounded white block every part of a card is, with a small head row.
|
||||
const nested = useContext(CardBodyContext)
|
||||
if (nested) {
|
||||
return (
|
||||
<SettingsBlock id={id} title={title} hint={hint} badge={badge} action={action} tone={tone}>
|
||||
{children}
|
||||
</SettingsBlock>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section ref={ref} id={id} className={`${flush ? '' : 'mb-5 '}overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary`}>
|
||||
<div
|
||||
className={`relative flex items-center gap-3 px-4 py-3 ${hasBody && open ? 'border-b border-edge-faint' : ''} ${collapsible ? 'transition-colors hover:brightness-[1.03] has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-[color:var(--text-primary)]' : ''}`}
|
||||
style={{ background: tint }}
|
||||
>
|
||||
{Icon && (
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${tone === 'danger' ? 'text-danger' : 'text-content-secondary'}`}>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 truncate font-bold text-content" style={fs(14, 'body')}>
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={hasBody ? bodyId : undefined}
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-start font-[inherit] text-inherit outline-none after:absolute after:inset-0 after:content-['']"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : title}
|
||||
</h2>
|
||||
{badge && <span className="relative z-[1] inline-flex">{badge}</span>}
|
||||
</div>
|
||||
{hint && <p className="m-0 mt-0.5 text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
</div>
|
||||
{action && <div className="relative z-[1] flex flex-none items-center gap-1.5">{action}</div>}
|
||||
{collapsible && (
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
className={`pointer-events-none flex-none text-content-faint transition-transform duration-200 motion-reduce:transition-none ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* The hidden wrapper has no classes of its own: a display utility on it would beat the attribute. */}
|
||||
{hasBody && (
|
||||
<div id={bodyId} hidden={!open}>
|
||||
<div className={bodyClassName ?? 'flex flex-col gap-4 p-4'}>{children}</div>
|
||||
<section id={id} className={`${flush ? '' : 'mb-5 '}overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary`}>
|
||||
<CardHead icon={Icon} iconFill={iconFill} title={title} hint={hint} badge={badge} action={action} tone={tone} />
|
||||
{children != null && (
|
||||
<div className={bodyClassName ?? 'flex flex-col gap-4 p-4'}>
|
||||
<CardBodyContext.Provider value>{children}</CardBodyContext.Provider>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two columns from xl up, for a settings page whose cards are mostly "label
|
||||
* left, control right": on a wide screen one column left the middle empty while
|
||||
* the page scrolled on. Two explicit columns, grouped by subject, rather than a
|
||||
* grid over a flat list, which pairs cards row by row and leaves a hole under
|
||||
* the shorter of two. A column with nothing to show drops out and the other
|
||||
* takes the whole width. The cards carry their own bottom margin, so only the
|
||||
* gap between the columns is set.
|
||||
*/
|
||||
export function SettingsColumns({ left, right }: { left: ReactNode; right: ReactNode }) {
|
||||
return (
|
||||
<div className="xl:flex xl:items-start xl:gap-5">
|
||||
<div className="min-w-0 flex-1 empty:hidden">{left}</div>
|
||||
<div className="min-w-0 flex-1 empty:hidden">{right}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A white box of rows separated by hairlines, the way the bag sidebar lists its bags. */
|
||||
export function SettingRows({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
@@ -184,12 +220,14 @@ export function SettingRow({ label, hint, control, children, stacked = false, di
|
||||
htmlFor?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`px-3.5 py-3 ${dimmed ? 'opacity-60' : ''}`}>
|
||||
<div className={`px-4 py-3 ${dimmed ? 'opacity-60' : ''}`}>
|
||||
<div className={stacked ? 'flex flex-col gap-2.5' : 'flex flex-wrap items-center gap-x-4 gap-y-2'}>
|
||||
{/* The basis only in a row: in the stacked column it would read as a height. */}
|
||||
<div className={stacked ? 'min-w-0' : 'min-w-0 flex-1 basis-56'}>
|
||||
<label htmlFor={htmlFor} className="block font-medium text-content" style={fs(13, 'body')}>{label}</label>
|
||||
{hint && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<label htmlFor={htmlFor} className="font-medium text-content" style={fs(13, 'body')}>{label}</label>
|
||||
{hint && <HintMark hint={hint} />}
|
||||
</div>
|
||||
</div>
|
||||
{control && <div className={stacked ? 'min-w-0' : 'flex flex-none items-center gap-2'}>{control}</div>}
|
||||
</div>
|
||||
@@ -198,6 +236,25 @@ export function SettingRow({ label, hint, control, children, stacked = false, di
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row's explanation as a small mark beside its label: the row stays one line,
|
||||
* the sentence opens on hover or focus. The text is in the page for a screen
|
||||
* reader all the same, read right after the label.
|
||||
*/
|
||||
export function HintMark({ hint }: { hint: ReactNode }) {
|
||||
const text = typeof hint === 'string' ? hint : ''
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only">{hint}</span>
|
||||
<Tooltip label={text || ' '} content={text ? undefined : hint} placement="top">
|
||||
<span aria-hidden className="inline-flex flex-none cursor-help text-content-faint transition-colors hover:text-content-muted">
|
||||
<Info size={13} strokeWidth={2} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** A quiet line of explanation inside a card body. */
|
||||
export function SettingsHint({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return <p className={`m-0 leading-normal text-content-faint ${className}`} style={fs(11.5)}>{children}</p>
|
||||
@@ -245,9 +302,9 @@ export function StatusPill({ tone = 'neutral', children, icon }: { tone?: 'neutr
|
||||
)
|
||||
}
|
||||
|
||||
/** The buttons of a settings card, in the dialogs' shapes. */
|
||||
export const SETTINGS_BUTTON = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
export const SETTINGS_BUTTON_PRIMARY = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'
|
||||
export const SETTINGS_BUTTON_DANGER = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger-soft px-3.5 py-2 font-medium text-danger hover:opacity-90 disabled:cursor-default disabled:opacity-50'
|
||||
/** The buttons of a settings card, in the dialogs' shapes and one size (text-control) everywhere. */
|
||||
export const SETTINGS_BUTTON = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50 text-control'
|
||||
export const SETTINGS_BUTTON_PRIMARY = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50 text-control'
|
||||
export const SETTINGS_BUTTON_DANGER = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger-soft px-3.5 py-2 font-medium text-danger hover:opacity-90 disabled:cursor-default disabled:opacity-50 text-control'
|
||||
/** A round icon action in a band or a row, named by its tooltip. */
|
||||
export const SETTINGS_ICON_BUTTON = 'grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint hover:text-content disabled:cursor-default disabled:opacity-50'
|
||||
|
||||
@@ -108,8 +108,6 @@ export function useMcpIntegration({ enabled, reloadClientsAfterWrite }: McpInteg
|
||||
|
||||
// MCP sub-tab state
|
||||
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth');
|
||||
const [configOpenOAuth, setConfigOpenOAuth] = useState(false);
|
||||
const [configOpenToken, setConfigOpenToken] = useState(false);
|
||||
|
||||
// MCP state
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([]);
|
||||
@@ -327,10 +325,6 @@ export function useMcpIntegration({ enabled, reloadClientsAfterWrite }: McpInteg
|
||||
setOauthIsMachine,
|
||||
activeMcpTab,
|
||||
setActiveMcpTab,
|
||||
configOpenOAuth,
|
||||
setConfigOpenOAuth,
|
||||
configOpenToken,
|
||||
setConfigOpenToken,
|
||||
mcpTokens,
|
||||
mcpModalOpen,
|
||||
setMcpModalOpen,
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function MAdmin() {
|
||||
const admin = useAdmin()
|
||||
const {
|
||||
demoMode, mcpEnabled, devMode, managed, navigate,
|
||||
activeTab, setActiveTab, stats, serverTimezone,
|
||||
activeTab: deskTab, setActiveTab, stats, serverTimezone,
|
||||
bagTrackingEnabled, collabFeatures,
|
||||
setShowCreateUser,
|
||||
updateInfo, setShowUpdateModal,
|
||||
@@ -107,6 +107,9 @@ export default function MAdmin() {
|
||||
// The install's own tabs (managed/index.tsx), after the built-in ones as on the desktop.
|
||||
...managedAdminTabs.map(tab => ({ id: tab.id, label: tab.label, icon: tab.Icon })),
|
||||
]
|
||||
// The desktop splits the settings into Security and Services; the phone keeps
|
||||
// them in its one Settings section, so either desktop tab (and a link to it) opens that.
|
||||
const activeTab = deskTab === 'security' || deskTab === 'services' ? 'settings' : deskTab
|
||||
const activeSection = sections.find((s) => s.id === activeTab) ?? sections[0]
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,7 +16,7 @@ import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
import AdminPluginsPanel from '../components/Admin/AdminPluginsPanel'
|
||||
import AdminStoragePanel from '../components/Admin/storage/AdminStoragePanel'
|
||||
import { Users, Map, Briefcase, Shield, FileText, RotateCcw, Save, SlidersHorizontal, UserCog, Puzzle, Blocks, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug, HardDrive } from 'lucide-react'
|
||||
import { Users, Map, Briefcase, Shield, FileText, RotateCcw, Save, SlidersHorizontal, UserCog, Puzzle, Blocks, ShieldCheck, Compass, Bell, Database, ScrollText, KeyRound, GitBranch, Bug, HardDrive } from 'lucide-react'
|
||||
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
|
||||
import { useAdmin } from './admin/useAdmin'
|
||||
import AdminUpdateBanner from './admin/AdminUpdateBanner'
|
||||
@@ -51,28 +51,30 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
|
||||
const gUsers = t('admin.group.users')
|
||||
const gConfig = t('admin.group.config')
|
||||
const gIntegration = t('admin.group.integration')
|
||||
const gMaintenance = t('admin.group.maintenance')
|
||||
const GROUP_LABELS = { users: gUsers, config: gConfig, integration: gIntegration, maintenance: gMaintenance }
|
||||
const GROUP_LABELS = { users: gUsers, config: gConfig, maintenance: gMaintenance }
|
||||
const TABS: PageSidebarTab[] = [
|
||||
{ id: 'users', label: t('admin.tabs.users'), icon: Users, group: gUsers },
|
||||
{ id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog, group: gUsers },
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
|
||||
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon, group: gConfig },
|
||||
// By what an admin comes for, not by how it is built: who may sign in, what
|
||||
// the install talks to, which features it offers and what they show.
|
||||
{ id: 'security', label: t('admin.tabs.security'), icon: ShieldCheck, group: gConfig },
|
||||
{ id: 'services', label: t('admin.tabs.services'), icon: Compass, group: gConfig },
|
||||
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle, group: gConfig },
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell, group: gConfig },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound, group: gConfig }] : []),
|
||||
// Plugins run third-party code that can send data anywhere; a hosted instance
|
||||
// offers none, so there is nothing to manage here (TREK_PLUGINS_ENABLED is off).
|
||||
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig }]),
|
||||
// Storage backends and their credentials are hoster-level configuration —
|
||||
// the server refuses the whole surface in managed mode (MANAGED_FORBIDDEN).
|
||||
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive, group: gConfig }]),
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell, group: gIntegration },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound, group: gIntegration }] : []),
|
||||
// Releases and update cadence belong to whoever operates the install.
|
||||
...(managed ? [] : [{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch, group: gIntegration }]),
|
||||
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive, group: gMaintenance }]),
|
||||
// Backups run off-volume on a managed install; the tab would offer a
|
||||
// schedule that competes with the real one.
|
||||
...(managed ? [] : [{ id: 'backup', label: t('admin.tabs.backup'), icon: Database, group: gMaintenance }]),
|
||||
// Releases and update cadence belong to whoever operates the install.
|
||||
...(managed ? [] : [{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch, group: gMaintenance }]),
|
||||
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText, group: gMaintenance },
|
||||
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug, group: gMaintenance }] : []),
|
||||
// Empty in this repository — see client/src/managed. Appended per group
|
||||
@@ -128,7 +130,6 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
<button type="button"
|
||||
onClick={saveDemoBaseline}
|
||||
className={`${SETTINGS_BUTTON_PRIMARY} flex-none`}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<Save size={14} strokeWidth={2.1} />
|
||||
{t('admin.demoBaseline.button')}
|
||||
@@ -144,7 +145,6 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
onTabChange={setActiveTab}
|
||||
footer=""
|
||||
>
|
||||
{/* Tab content */}
|
||||
{activeTab === 'users' && (
|
||||
<AdminUsersTab admin={admin} t={t} locale={locale} />
|
||||
)}
|
||||
@@ -159,14 +159,21 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
|
||||
{activeTab === 'addons' && (
|
||||
<div className="space-y-6">
|
||||
<AddonManager bagTrackingEnabled={bagTrackingEnabled} onToggleBagTracking={toggleBagTracking} collabFeatures={collabFeatures} onToggleCollabFeature={toggleCollabFeature} />
|
||||
<AddonManager
|
||||
bagTrackingEnabled={bagTrackingEnabled}
|
||||
onToggleBagTracking={toggleBagTracking}
|
||||
collabFeatures={collabFeatures}
|
||||
onToggleCollabFeature={toggleCollabFeature}
|
||||
// How the integrations reach the world: live flight status, booking mails.
|
||||
aside={<AdminSettingsTab admin={admin} t={t} part="features" />}
|
||||
/>
|
||||
<PaddleRegionsPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<AdminSettingsTab admin={admin} t={t} />
|
||||
)}
|
||||
{activeTab === 'security' && <AdminSettingsTab admin={admin} t={t} part="security" />}
|
||||
|
||||
{activeTab === 'services' && <AdminSettingsTab admin={admin} t={t} part="services" />}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<AdminNotificationsTab admin={admin} t={t} />
|
||||
|
||||
@@ -6,7 +6,6 @@ import { buildUser } from '../../tests/helpers/factories';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useAddonStore } from '../store/addonStore';
|
||||
import SettingsPage from './SettingsPage';
|
||||
import { openSettingsCards } from '../../tests/helpers/settingsCards';
|
||||
|
||||
// Mock heavy settings sub-tabs to focus on page-level concerns
|
||||
vi.mock('../components/Settings/DisplaySettingsTab', () => ({
|
||||
@@ -36,8 +35,6 @@ vi.mock('../components/Settings/AboutTab', () => ({
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
// The cards start shut; these tests read into them.
|
||||
openSettingsCards();
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||
});
|
||||
|
||||
@@ -11,10 +11,15 @@ const EYEBROW = 'text-center font-geist font-bold uppercase tracking-[.08em] tex
|
||||
|
||||
// Per-event × per-channel admin notification preference matrix.
|
||||
// Loads its own data and auto-saves each toggle.
|
||||
export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
|
||||
export default function AdminNotificationsPanel({ t, toast, bare = false }: {
|
||||
t: (k: string) => string
|
||||
toast: ReturnType<typeof useToast>
|
||||
/** Inside the admin notifications card, which carries the title: the matrix alone. */
|
||||
bare?: boolean
|
||||
}) {
|
||||
const { matrix, saving, visibleChannels, toggle } = useAdminNotificationMatrix(t, toast)
|
||||
|
||||
const card = (children: React.ReactNode) => (
|
||||
const card = (children: React.ReactNode) => bare ? <>{children}</> : (
|
||||
<SettingsCard icon={BellRing} title={t('admin.tabs.notifications')} hint={t('admin.notifications.adminNotificationsHint')}>
|
||||
{children}
|
||||
</SettingsCard>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useId } from 'react'
|
||||
import { Bell, BellRing, CalendarClock, Mail, Save, Send, Smartphone, Webhook } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Bell, BellRing, Mail, Save, Send, Webhook } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { useAdmin } from './useAdmin'
|
||||
import AdminNotificationsPanel from './AdminNotificationsPanel'
|
||||
@@ -9,7 +8,7 @@ import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../components/
|
||||
import { MASK, useAdminNotificationSettings } from '../../components/Admin/useAdminNotificationSettings'
|
||||
import ToggleSwitch from '../../components/Settings/ToggleSwitch'
|
||||
import {
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard,
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, SettingsColumns,
|
||||
} from '../../components/Settings/settingsKit'
|
||||
import { EditorField, GRID_2, INPUT } from '../../components/shared/dialogParts'
|
||||
import { fs } from '../../components/shared/DialogShell'
|
||||
@@ -19,9 +18,6 @@ interface AdminNotificationsTabProps {
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/** The glyph on each switch-only channel's card; anything else is the push channel. */
|
||||
const CHANNEL_ICONS: Record<string, LucideIcon> = { webhook: Webhook, ntfy: BellRing }
|
||||
|
||||
/** The save and test buttons under a card's fields, on a hairline of their own. */
|
||||
function CardActions({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <div className="flex flex-wrap items-center gap-2 border-t border-edge-faint pt-4" style={fs(13, 'body')}>{children}</div>
|
||||
@@ -78,115 +74,102 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
</EditorField>
|
||||
)
|
||||
|
||||
const tripRemindersCard = (
|
||||
<SettingsCard
|
||||
icon={CalendarClock}
|
||||
title={t('admin.notifications.tripReminders.title')}
|
||||
hint={t('admin.notifications.tripReminders.hint')}
|
||||
action={
|
||||
<ToggleSwitch
|
||||
on={tripRemindersActive}
|
||||
label={t('admin.notifications.tripReminders.title')}
|
||||
onToggle={toggleTripReminders}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
return (<>
|
||||
{/* Two columns from xl up, the same shape as the Settings tab. Most of these
|
||||
cards are a title with a switch on the far right, so on a wide screen the
|
||||
middle stayed empty while the page still scrolled past the three tall ones.
|
||||
Two explicit columns rather than a grid over the flat list: a plain grid
|
||||
pairs cards row by row and leaves a hole under the shorter one, and the SMTP
|
||||
card is taller than all the toggle rows together. Grouped by audience, not by
|
||||
height — the channels a user can receive on the left, everything the operator
|
||||
sends or receives themselves on the right. The channel switches stay
|
||||
together because they all write the same notification_channels list.
|
||||
On a managed install the two admin-target cards are gone and the right column
|
||||
is the matrix alone — still a column, so the grid keeps working. Each card
|
||||
brings its own bottom margin, so the columns only need the gap between them. */}
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
|
||||
<div className="min-w-0">
|
||||
{/* The relay is the operator's: their host, their credential, their sending
|
||||
reputation. An instance that could point it elsewhere would send under a
|
||||
domain it does not own. */}
|
||||
{!managed && (
|
||||
<SettingsCard
|
||||
icon={Mail}
|
||||
title={t('admin.notifications.emailPanel.title')}
|
||||
hint={t('admin.smtp.hint')}
|
||||
action={<ToggleSwitch on={emailActive} label={t('admin.notifications.emailPanel.title')} onToggle={() => channels.toggle('email')} />}
|
||||
>
|
||||
{/* Greyed and out of reach while the channel is off, never hidden. */}
|
||||
<div data-testid="smtp-fields" className={`flex flex-col gap-4 transition-opacity ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{smtpLoaded && (<>
|
||||
<div className={GRID_2}>
|
||||
{smtpField(SMTP_FIELDS[0])}
|
||||
{smtpField(SMTP_FIELDS[1])}
|
||||
</div>
|
||||
<div className={GRID_2}>
|
||||
{smtpField(SMTP_FIELDS[2])}
|
||||
{smtpField(SMTP_FIELDS[3])}
|
||||
</div>
|
||||
{smtpField(SMTP_FIELDS[4])}
|
||||
</>)}
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('admin.smtp.skipTls')}
|
||||
hint={t('admin.smtp.skipTlsHint')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={smtpValues.smtp_skip_tls_verify === 'true'}
|
||||
label={t('admin.smtp.skipTls')}
|
||||
onToggle={() => {
|
||||
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{/* Every channel users can receive on is one switch in one card, the SMTP
|
||||
relay's fields a section under them; the operator's own targets and the
|
||||
admin-only matrix on the right. */}
|
||||
<SettingsColumns
|
||||
left={
|
||||
<SettingsCard icon={Bell} title={t('admin.notifications.channels')}>
|
||||
<SettingRows>
|
||||
{/* The relay is the operator's on a managed install: their host, their
|
||||
credential, their sending reputation. */}
|
||||
{!managed && (
|
||||
<SettingRow
|
||||
label={t('admin.notifications.emailPanel.title')}
|
||||
hint={t('admin.smtp.hint')}
|
||||
control={<ToggleSwitch on={emailActive} label={t('admin.notifications.emailPanel.title')} onToggle={() => channels.toggle('email')} />}
|
||||
/>
|
||||
)}
|
||||
{SWITCH_ONLY_CHANNELS.map(ch => (
|
||||
<SettingRow
|
||||
key={ch.id}
|
||||
label={t(ch.titleKey)}
|
||||
hint={t(ch.hintKey)}
|
||||
control={<ToggleSwitch on={channels.isActive(ch.id)} label={t(ch.titleKey)} onToggle={() => channels.toggle(ch.id)} />}
|
||||
/>
|
||||
))}
|
||||
<SettingRow
|
||||
label={t('admin.notifications.inappPanel.title')}
|
||||
hint={t('admin.notifications.inappPanel.hint')}
|
||||
control={<LockedSwitch />}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('admin.notifications.tripReminders.title')}
|
||||
hint={t('admin.notifications.tripReminders.hint')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={tripRemindersActive}
|
||||
label={t('admin.notifications.tripReminders.title')}
|
||||
onToggle={toggleTripReminders}
|
||||
/>
|
||||
</SettingRows>
|
||||
</div>
|
||||
<CardActions>
|
||||
<button type="button" onClick={saveNotifications} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Save size={14} strokeWidth={2.2} />{t('common.save')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={testSmtp}
|
||||
disabled={!smtpConfigured}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<Send size={14} strokeWidth={2.2} />{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
</CardActions>
|
||||
</SettingsCard>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
{/* Webhook, Ntfy and Web Push: a title, a hint and the switch each. Nothing
|
||||
to set below them, so the card is its head band alone. */}
|
||||
{SWITCH_ONLY_CHANNELS.map(ch => (
|
||||
<SettingsCard
|
||||
key={ch.id}
|
||||
icon={CHANNEL_ICONS[ch.id] ?? Smartphone}
|
||||
title={t(ch.titleKey)}
|
||||
hint={t(ch.hintKey)}
|
||||
action={<ToggleSwitch on={channels.isActive(ch.id)} label={t(ch.titleKey)} onToggle={() => channels.toggle(ch.id)} />}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* In-App Panel */}
|
||||
<SettingsCard
|
||||
icon={Bell}
|
||||
title={t('admin.notifications.inappPanel.title')}
|
||||
hint={t('admin.notifications.inappPanel.hint')}
|
||||
action={<LockedSwitch />}
|
||||
/>
|
||||
|
||||
{!managed && tripRemindersCard}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{/* The SMTP relay, a section of this card: greyed and out of reach while
|
||||
the email channel is off, never hidden. */}
|
||||
{!managed && (
|
||||
<SettingsCard icon={Mail} title={t('admin.notifications.emailPanel.title')}>
|
||||
<div data-testid="smtp-fields" className={`flex flex-col gap-4 transition-opacity ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{smtpLoaded && (<>
|
||||
<div className={GRID_2}>
|
||||
{smtpField(SMTP_FIELDS[0])}
|
||||
{smtpField(SMTP_FIELDS[1])}
|
||||
</div>
|
||||
<div className={GRID_2}>
|
||||
{smtpField(SMTP_FIELDS[2])}
|
||||
{smtpField(SMTP_FIELDS[3])}
|
||||
</div>
|
||||
{smtpField(SMTP_FIELDS[4])}
|
||||
</>)}
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('admin.smtp.skipTls')}
|
||||
hint={t('admin.smtp.skipTlsHint')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={smtpValues.smtp_skip_tls_verify === 'true'}
|
||||
label={t('admin.smtp.skipTls')}
|
||||
onToggle={() => {
|
||||
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
</div>
|
||||
<CardActions>
|
||||
<button type="button" onClick={saveNotifications} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<Save size={14} strokeWidth={2.2} />{t('common.save')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={testSmtp}
|
||||
disabled={!smtpConfigured}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
<Send size={14} strokeWidth={2.2} />{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
</CardActions>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</SettingsCard>
|
||||
}
|
||||
right={
|
||||
// What the operator receives about running the instance: one card, the
|
||||
// two targets as its blocks, then which event goes out over which channel.
|
||||
<SettingsCard icon={BellRing} title={t('admin.notifications.adminAlerts')} hint={t('admin.notifications.adminNotificationsHint')}>
|
||||
{/* Admin alerts are about running the instance (version notices, and what else
|
||||
lands there later). On a managed install those go to whoever runs it. */}
|
||||
{!managed && (<>
|
||||
@@ -258,7 +241,6 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
<button type="button"
|
||||
onClick={clearNtfyToken}
|
||||
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
@@ -286,12 +268,10 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
{/* The matrix decides which of those channels each admin-only event goes out
|
||||
over, so it belongs under the targets it routes to rather than across the
|
||||
full width below both columns. */}
|
||||
<AdminNotificationsPanel t={t} toast={toast} />
|
||||
{/* Without the admin alert cards the right column is short, so the
|
||||
reminder switch moves over on a managed install. */}
|
||||
{managed && tripRemindersCard}
|
||||
</div>
|
||||
</div>
|
||||
<AdminNotificationsPanel t={t} toast={toast} bare />
|
||||
</SettingsCard>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* What users' own notifications start as, across the full width: one row per
|
||||
user event, one column per channel users can pick (#1536). */}
|
||||
|
||||
@@ -13,9 +13,20 @@ import AdminSettingsTab from './AdminSettingsTab';
|
||||
type Spy = ReturnType<typeof vi.fn>;
|
||||
type Spies = Record<string, Spy> & { toast: Record<string, Spy> };
|
||||
|
||||
/** The three tabs the settings are split into (security, services, features), on one page. */
|
||||
function AllParts({ admin, t }: { admin: AdminHook; t: ReturnType<typeof useTranslation>['t'] }) {
|
||||
return (
|
||||
<>
|
||||
<AdminSettingsTab admin={admin} t={t} part="security" />
|
||||
<AdminSettingsTab admin={admin} t={t} part="services" />
|
||||
<AdminSettingsTab admin={admin} t={t} part="features" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({ admin }: { admin: AdminHook }) {
|
||||
const { t } = useTranslation();
|
||||
return <AdminSettingsTab admin={admin} t={t} />;
|
||||
return <AllParts admin={admin} t={t} />;
|
||||
}
|
||||
|
||||
function renderTab(overrides: Record<string, unknown> = {}) {
|
||||
@@ -71,7 +82,7 @@ function OidcHarness() {
|
||||
const { t } = useTranslation();
|
||||
const [oidcConfig, setOidcConfig] = React.useState<OidcConfig>(EMPTY_OIDC);
|
||||
const admin = buildAdminHook({ oidcConfig, setOidcConfig });
|
||||
return <AdminSettingsTab admin={admin} t={t} />;
|
||||
return <AllParts admin={admin} t={t} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -88,7 +99,8 @@ describe('AdminSettingsTab', () => {
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Authentication Methods' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Passkey login' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /require two-factor/i })).toBeInTheDocument();
|
||||
// Requiring two-factor is a switch of the sign-in card now, not a card of its own.
|
||||
expect(screen.getByText(/require two-factor/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Allowed File Types' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'API Keys' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /single sign-on/i })).toBeInTheDocument();
|
||||
@@ -340,13 +352,11 @@ describe('AdminSettingsTab', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022f: a managed install moves file types to the right column', () => {
|
||||
// With place search and the keys gone the right column would stand empty.
|
||||
it('FE-ADMSET-022f: a managed install keeps the file types card on the services tab', () => {
|
||||
// Place search and the keys are the operator's there; the file types stay the admin's.
|
||||
renderTab({ managed: true });
|
||||
|
||||
const right = screen.getByRole('heading', { name: 'Allowed File Types' }).closest('.min-w-0');
|
||||
expect(right).not.toBeNull();
|
||||
expect(within(right as HTMLElement).queryByRole('heading', { name: /two-factor/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Allowed File Types' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import CustomSelect from '../../components/shared/CustomSelect'
|
||||
import { fs } from '../../components/shared/DialogShell'
|
||||
import { EditorField, GRID_2, INPUT } from '../../components/shared/dialogParts'
|
||||
import {
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, SettingsHint, StatusPill,
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, SettingsColumns, SettingsHint, StatusPill,
|
||||
} from '../../components/Settings/settingsKit'
|
||||
import GoogleOptions from './GoogleOptions'
|
||||
import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow'
|
||||
@@ -26,6 +26,12 @@ import type { useAdmin } from './useAdmin'
|
||||
interface AdminSettingsTabProps {
|
||||
admin: ReturnType<typeof useAdmin>
|
||||
t: TranslationFn
|
||||
/**
|
||||
* Which tab this renders: sign-in and its safety (security), what the
|
||||
* install talks to outside (services), or the setup of the features the
|
||||
* addons tab switches on (features).
|
||||
*/
|
||||
part: 'security' | 'services' | 'features'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +108,7 @@ function KeyField({ shown, value, onChange, inputProps, label, onToggle }: {
|
||||
function SaveBar({ onClick, saving, label }: { onClick: () => void; saving: boolean; label: string }) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={onClick} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
<button type="button" onClick={onClick} disabled={saving} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{label}
|
||||
</button>
|
||||
@@ -110,9 +116,9 @@ function SaveBar({ onClick, saving, label }: { onClick: () => void; saving: bool
|
||||
)
|
||||
}
|
||||
|
||||
// "Settings" admin tab: auth methods, require-MFA, allowed file types, API keys,
|
||||
// OIDC config and the danger zone. Pure layout around the useAdmin hook.
|
||||
export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): React.ReactElement {
|
||||
// The admin's security, services and feature setup tabs, cut from what was one
|
||||
// "Settings" tab. Pure layout around the useAdmin hook.
|
||||
export default function AdminSettingsTab({ admin, t, part }: AdminSettingsTabProps): React.ReactElement {
|
||||
const {
|
||||
toast,
|
||||
placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled,
|
||||
@@ -143,8 +149,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
const passwordLoginLocked = !passwordLogin && !oidcLogin
|
||||
const oidcLoginLocked = !passwordLogin && oidcLogin
|
||||
|
||||
// On a managed install the right column loses place search, routing and the
|
||||
// keys to the operator, so file types move over and the columns stay even.
|
||||
const fileTypesCard = (
|
||||
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
|
||||
<div>
|
||||
@@ -166,18 +170,9 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
</SettingsCard>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Two columns from xl up. Nearly every card here is "label left,
|
||||
switch right", so on a wide screen the middle stayed empty while the
|
||||
page scrolled for ages. Two explicit columns rather than a grid over
|
||||
the flat list: a plain grid pairs cards row by row and leaves a hole
|
||||
under whichever of the pair is shorter, and these differ by hundreds
|
||||
of pixels. Grouped by subject, not by height — access and identity
|
||||
left, integrations and data right. The cards carry their own bottom
|
||||
margin, so only the column gap is set here. */}
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
|
||||
<div className="min-w-0">
|
||||
// Each card is built once; the parts below pick theirs.
|
||||
const authCard = (
|
||||
<>
|
||||
{/* Authentication Methods */}
|
||||
<SettingsCard icon={LockKeyhole} title={t('admin.authMethods')}>
|
||||
{envOverrideOidcOnly && <Callout>{t('admin.envOverrideHint')}</Callout>}
|
||||
@@ -251,12 +246,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingRows>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Passkey (WebAuthn) login */}
|
||||
<SettingsCard icon={Fingerprint} title={t('admin.passkey.title')} hint={t('admin.passkey.cardHint')}>
|
||||
<SettingRows>
|
||||
{/* Passkeys and the 2FA rule are ways in too, so they sit with the others. */}
|
||||
<SettingRow
|
||||
label={t('admin.passkey.login')}
|
||||
hint={t('admin.passkey.loginHint')}
|
||||
@@ -268,17 +258,38 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('admin.requireMfa')}
|
||||
hint={t('admin.requireMfaHint')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={requireMfa}
|
||||
label={t('admin.requireMfa')}
|
||||
onToggle={() => handleToggleRequireMfa(!requireMfa)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
{passkeyLogin && !passkeyConfigured && <Callout>{t('admin.passkey.notConfigured')}</Callout>}
|
||||
|
||||
</SettingsCard>
|
||||
</>
|
||||
)
|
||||
// Where passkeys bind: an expert setting, a row of the setup list. The switch
|
||||
// to offer passkeys at all is in the sign-in card.
|
||||
const passkeyCard = (
|
||||
<>
|
||||
{/* The domain passkeys bind to and the origins that may present them
|
||||
follow from the address the instance is served on, which the operator
|
||||
owns. Getting either wrong invalidates every enrolled passkey, and on
|
||||
a shared parent domain a wrong RP ID reaches past this instance
|
||||
entirely — so they are pinned per container, not offered here. The
|
||||
switch above stays: whether to offer passkeys at all is a house rule. */}
|
||||
{!managed && (<>
|
||||
entirely — so they are pinned per container, not offered here. */}
|
||||
{!managed && (
|
||||
<SettingsCard
|
||||
icon={Fingerprint}
|
||||
title={t('admin.passkey.title')}
|
||||
hint={t('admin.passkey.cardHint')}
|
||||
badge={webauthnRpId ? <StatusPill>{webauthnRpId}</StatusPill> : undefined}
|
||||
>
|
||||
<EditorField label={t('admin.passkey.rpId')} htmlFor="admin-webauthn-rp-id" hint={t('admin.passkey.rpIdHint')}>
|
||||
<input
|
||||
id="admin-webauthn-rp-id"
|
||||
@@ -300,32 +311,27 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
/>
|
||||
</EditorField>
|
||||
<SaveBar onClick={handleSaveWebauthn} saving={savingWebauthn} label={t('common.save')} />
|
||||
</>)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Require 2FA for all users */}
|
||||
<SettingsCard icon={ShieldCheck} title={t('admin.requireMfa')}>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('admin.requireMfa')}
|
||||
hint={t('admin.requireMfaHint')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={requireMfa}
|
||||
label={t('admin.requireMfa')}
|
||||
onToggle={() => handleToggleRequireMfa(!requireMfa)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
</SettingsCard>
|
||||
|
||||
</SettingsCard>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
const oidcCard = (
|
||||
<>
|
||||
{/* An issuer the instance names can assert any address as verified, and the
|
||||
discovery calls leave from inside the operator’s network. Sign-on is theirs
|
||||
to wire, so the fields are not offered. */}
|
||||
{!managed && (
|
||||
/* OIDC / SSO Configuration */
|
||||
<SettingsCard icon={LogIn} title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')}>
|
||||
<SettingsCard
|
||||
icon={LogIn}
|
||||
title={t('admin.oidcTitle')}
|
||||
hint={t('admin.oidcSubtitle')}
|
||||
badge={
|
||||
oidcConfigured
|
||||
? <StatusPill tone="success" icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.configured')}</StatusPill>
|
||||
: <StatusPill icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.notSetUp')}</StatusPill>
|
||||
}
|
||||
>
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('admin.oidcDisplayName')} htmlFor="oidc-display-name">
|
||||
<input
|
||||
@@ -390,9 +396,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
/>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{!managed && fileTypesCard}
|
||||
|
||||
</>
|
||||
)
|
||||
const flightCard = (
|
||||
<>
|
||||
{/* Live flight status: open ADS-B data, no key, on by default; the admin may keep it off. */}
|
||||
<SettingsCard icon={Plane} title={t('flightLive.adminTitle')} hint={managed ? t('flightLive.adminHintManaged') : t('flightLive.adminHint')}>
|
||||
<SettingRows>
|
||||
@@ -410,19 +417,17 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
/>
|
||||
</SettingRows>
|
||||
</SettingsCard>
|
||||
|
||||
</>
|
||||
)
|
||||
const mailCard = (
|
||||
<>
|
||||
{/* Booking mail import: how forwarded booking mails reach this install. On a managed
|
||||
install the operator runs the TREK Cloud relay, so the card is not shown there. */}
|
||||
{!managed && <MailIngestAdminCard />}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{managed && fileTypesCard}
|
||||
{/* Inside the place search card elsewhere; that card is the operator's here,
|
||||
sharing place details is still the admin's decision. */}
|
||||
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
const servicesCard = (
|
||||
<>
|
||||
{/* Place search and routing: TREK's own services first, then which provider
|
||||
answers search and transit, and the search log. Apart from the keys, so a
|
||||
keyless install reads its whole setup in one card. Everything here saves
|
||||
@@ -539,7 +544,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
const apiKeysCard = (
|
||||
<>
|
||||
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
|
||||
the operator's on a managed install, so the card is not shown there
|
||||
(server managed.ts), and neither is the provider pick in the card
|
||||
@@ -570,7 +578,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
onClick={() => handleValidateKey('maps')}
|
||||
disabled={!mapsKeyTestable || validating.maps}
|
||||
className={`${SETTINGS_BUTTON} flex-none whitespace-nowrap`}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
{validating.maps ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
@@ -695,9 +702,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
<SaveBar onClick={handleSaveApiKeys} saving={savingKeys} label={t('common.save')} />
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
const dangerCard = (
|
||||
<>
|
||||
{/* Full width, and last. A destructive action should not sit beside a
|
||||
harmless toggle where a mis-aimed click can reach it. */}
|
||||
{/* Rotating the secret signs every user out and fixes nothing an instance admin
|
||||
@@ -713,7 +721,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
<button type="button"
|
||||
onClick={() => setShowRotateJwtModal(true)}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{t('admin.rotateJwt.button')}
|
||||
@@ -723,6 +730,55 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
</SettingRows>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
// What used to be one "Settings" tab, split by what an admin comes for:
|
||||
// who may sign in and how (security), what the install talks to outside
|
||||
// (services), and how the features in the addons tab are set up there.
|
||||
if (part === 'features') {
|
||||
return (
|
||||
<>
|
||||
{flightCard}
|
||||
{mailCard}
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (part === 'services') {
|
||||
// Place search and the services on the left, the keys and file types on the right.
|
||||
return (
|
||||
<SettingsColumns
|
||||
left={<>
|
||||
{/* Inside the place search card elsewhere; that card is the operator's here,
|
||||
sharing place details is still the admin's decision. */}
|
||||
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
|
||||
{servicesCard}
|
||||
</>}
|
||||
right={<>
|
||||
{apiKeysCard}
|
||||
{fileTypesCard}
|
||||
</>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Who may sign in and what it takes (the passkey domain, single sign-on),
|
||||
// with the one destructive action in its own card.
|
||||
return (
|
||||
// Split by length, not by kind: single sign-on is the one long form, so the
|
||||
// right column holds it and the short danger zone under it.
|
||||
<SettingsColumns
|
||||
left={
|
||||
<>
|
||||
{authCard}
|
||||
{passkeyCard}
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<>
|
||||
{oidcCard}
|
||||
{dangerCard}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -297,7 +297,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
if (editingUser) setPasskeyResetUser(editingUser);
|
||||
}}
|
||||
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Fingerprint size={14} /> {t('admin.passkey.reset')}
|
||||
</button>
|
||||
@@ -313,7 +312,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
type="button"
|
||||
onClick={() => setMfaResetUser(editingUser)}
|
||||
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<ShieldOff size={14} /> {t('admin.mfa.reset')}
|
||||
</button>
|
||||
@@ -359,7 +357,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<ExternalLink size={14} strokeWidth={2.1} className="flex-none" />
|
||||
{t('admin.update.button')}
|
||||
@@ -525,7 +522,6 @@ function SsoUnlinkSection({
|
||||
type="button"
|
||||
onClick={() => setAsking(true)}
|
||||
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Unlink size={14} /> {t('admin.sso.unlink')}
|
||||
</button>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
title={t('admin.tabs.users')}
|
||||
hint={<>{users.length} {t('admin.stats.users', { count: users.length })}</>}
|
||||
action={
|
||||
<button type="button" onClick={() => setShowCreateUser(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={() => setShowCreateUser(true)} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
<UserPlus size={14} strokeWidth={2.1} />
|
||||
{t('admin.createUser')}
|
||||
</button>
|
||||
@@ -146,7 +146,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
title={t('admin.invite.title')}
|
||||
hint={t('admin.invite.subtitle')}
|
||||
action={
|
||||
<button type="button" onClick={() => setShowCreateInvite(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={() => setShowCreateInvite(true)} className={SETTINGS_BUTTON}>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('admin.invite.create')}
|
||||
</button>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import FoldSummary from './FoldSummary';
|
||||
|
||||
describe('FoldSummary', () => {
|
||||
it('heads the fold with a turning chevron and its own label', () => {
|
||||
const { container } = render(
|
||||
<details className="group">
|
||||
<FoldSummary>
|
||||
<span>More</span>
|
||||
</FoldSummary>
|
||||
<p>inside</p>
|
||||
</details>
|
||||
);
|
||||
const summary = container.querySelector('summary') as HTMLElement;
|
||||
expect(summary).toContainElement(screen.getByText('More'));
|
||||
const chevron = summary.querySelector('svg') as SVGElement;
|
||||
expect(chevron).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(chevron.getAttribute('class')).toContain('group-open:rotate-90');
|
||||
expect(summary.firstElementChild).toBe(chevron);
|
||||
});
|
||||
|
||||
it('opens and closes the fold it heads', () => {
|
||||
const { container } = render(
|
||||
<details className="group">
|
||||
<FoldSummary>More</FoldSummary>
|
||||
<p>inside</p>
|
||||
</details>
|
||||
);
|
||||
const details = container.querySelector('details') as HTMLDetailsElement;
|
||||
expect(details.open).toBe(false);
|
||||
fireEvent.click(container.querySelector('summary') as HTMLElement);
|
||||
expect(details.open).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* The clickable head of a native <details> fold on the admin settings page: a
|
||||
* chevron that turns when the fold opens, then whatever the fold calls itself.
|
||||
* The surrounding <details> needs the `group` class for the chevron to turn.
|
||||
*/
|
||||
export default function FoldSummary({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className="flex-none text-content-faint transition-transform group-open:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{children}
|
||||
</summary>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from 'react'
|
||||
import { fs } from '../../components/shared/DialogShell'
|
||||
import { StatusPill } from '../../components/Settings/settingsKit'
|
||||
import FoldSummary from './FoldSummary'
|
||||
import { SettingsBlock, StatusPill } from '../../components/Settings/settingsKit'
|
||||
|
||||
interface GoogleOptionsProps {
|
||||
/** "Google options" — the heading of the fold. */
|
||||
@@ -19,19 +17,14 @@ interface GoogleOptionsProps {
|
||||
* an admin opens this card at all. The switches are the opposite — set once,
|
||||
* then never touched, and there are five of them.
|
||||
*
|
||||
* A native <details> rather than a state hook: keyboard operable and announced
|
||||
* to screen readers without writing any of that, and it survives a re-render
|
||||
* without state. Drawn as a box of rows (settingsKit's SettingRows), the
|
||||
* summary being its first row.
|
||||
* Shown open: the settings pages fold nothing away.
|
||||
*/
|
||||
export default function GoogleOptions({ title, summary, children }: GoogleOptionsProps): React.ReactElement {
|
||||
return (
|
||||
<details className="group overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<FoldSummary>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(13, 'body')}>{title}</span>
|
||||
<StatusPill>{summary}</StatusPill>
|
||||
</FoldSummary>
|
||||
<div className="divide-y divide-edge-faint border-t border-edge-faint bg-surface-card">{children}</div>
|
||||
</details>
|
||||
// A block of the card: its title and the count of switches on as the block's
|
||||
// head, its rows inside.
|
||||
<SettingsBlock title={title} badge={<StatusPill>{summary}</StatusPill>} flush>
|
||||
{children}
|
||||
</SettingsBlock>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import { fs } from '../../components/shared/DialogShell'
|
||||
import { StatusPill } from '../../components/Settings/settingsKit'
|
||||
import { SettingsBlock, StatusPill } from '../../components/Settings/settingsKit'
|
||||
|
||||
interface ProviderBlockProps {
|
||||
title: string
|
||||
@@ -17,21 +16,16 @@ interface ProviderBlockProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider inside the API card, in the same shape as the TREK block above
|
||||
* it, so the page reads as a list of comparable options rather than one
|
||||
* highlighted thing and some loose fields underneath. A white box on the
|
||||
* card's grey body, its name in a head row, like a bag in the packing sidebar.
|
||||
* One provider inside the API card: its name in a head row, a pill beside it,
|
||||
* its fields below, as one rounded block of the card (SettingsBlock), so the
|
||||
* card reads as a list of comparable options.
|
||||
*/
|
||||
export default function ProviderBlock({
|
||||
title, badge, tone = 'muted', children,
|
||||
}: ProviderBlockProps): React.ReactElement {
|
||||
return (
|
||||
<section className="overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-edge-faint px-3.5 py-2.5">
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>{title}</h3>
|
||||
{badge && <StatusPill tone={tone === 'caution' ? 'warning' : 'neutral'}>{badge}</StatusPill>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-3.5">{children}</div>
|
||||
</section>
|
||||
<SettingsBlock title={title} badge={badge ? <StatusPill tone={tone === 'caution' ? 'warning' : 'neutral'}>{badge}</StatusPill> : undefined}>
|
||||
{children}
|
||||
</SettingsBlock>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Library, MapPin, Route, Search, X } from 'lucide-react'
|
||||
import TrekMark from '../../components/shared/TrekMark'
|
||||
import { fs } from '../../components/shared/DialogShell'
|
||||
import { StatusPill } from '../../components/Settings/settingsKit'
|
||||
import FoldSummary from './FoldSummary'
|
||||
import ServiceIdentityPanel from '../../components/Admin/ServiceIdentityPanel'
|
||||
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import {
|
||||
TREK_API_SOURCES as SOURCES,
|
||||
trekApiAddressFeatures,
|
||||
trekApiFacts,
|
||||
trekApiFields,
|
||||
trekApiRoutingFeatures,
|
||||
} from '../../components/Admin/trekApiModel'
|
||||
import { TREK_API_SOURCES as SOURCES, trekApiFacts } from '../../components/Admin/trekApiModel'
|
||||
|
||||
interface TrekApiCardProps {
|
||||
t: TranslationFn
|
||||
@@ -35,30 +27,13 @@ interface TrekApiCardProps {
|
||||
* three places on the server rather than promised here, and the allowance below
|
||||
* the facts is what the service itself reports for this installation.
|
||||
*/
|
||||
const CHIP = 'rounded-full border px-2.5 py-[3px] font-medium'
|
||||
|
||||
/** One of the lists under "more": an icon and a name, chips, a note. */
|
||||
function FactGroup({ icon, title, note, children }: { icon: React.ReactNode; title: string; note: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-3.5 py-3">
|
||||
<p className="m-0 flex items-center gap-1.5 font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0" style={fs(11.5)}>{children}</ul>
|
||||
<p className="m-0 leading-normal text-content-faint" style={fs(11.5)}>{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): React.ReactElement {
|
||||
const fields = trekApiFields(t)
|
||||
const facts = trekApiFacts(t)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[14px] border bg-surface-card shadow-sm"
|
||||
<section className="overflow-hidden rounded-[12px] border bg-surface-card"
|
||||
style={{ borderColor: 'color-mix(in srgb, var(--accent) 40%, transparent)' }}>
|
||||
<div className="flex flex-col gap-3 px-3.5 pb-3.5 pt-3.5">
|
||||
<div className="flex flex-col gap-3 p-3.5">
|
||||
{/* The recommendation, moved here from the Google field. It is the whole
|
||||
point of the block: a key should be the exception, not the default.
|
||||
A pill beside the mark rather than a banner, so it does not shout
|
||||
@@ -72,13 +47,12 @@ export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): R
|
||||
{t('admin.trekApi.tagline')}
|
||||
</p>
|
||||
|
||||
<ul className="m-0 grid list-none grid-cols-1 gap-x-4 gap-y-1.5 p-0 sm:grid-cols-2">
|
||||
{/* What it offers as compact badges that wrap, not a list of rows. */}
|
||||
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
||||
{facts.map(({ Icon, text }) => (
|
||||
<li key={text} className="flex min-w-0 items-center gap-2 text-content-secondary" style={fs(12, 'body')}>
|
||||
<span className="grid h-6 w-6 flex-none place-items-center rounded-[8px] bg-surface-tertiary text-content-muted">
|
||||
<Icon size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">{text}</span>
|
||||
<li key={text} className="inline-flex items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2.5 py-1 text-content-secondary" style={fs(11.5)}>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden="true" className="flex-none text-content-muted" />
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -87,72 +61,11 @@ export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): R
|
||||
<PlaceContribPanel />
|
||||
</div>
|
||||
|
||||
<details className="group border-t border-edge-faint">
|
||||
<FoldSummary>
|
||||
<span className="font-medium text-content" style={fs(13, 'body')}>{t('admin.trekApi.more')}</span>
|
||||
</FoldSummary>
|
||||
<div className="divide-y divide-edge-faint border-t border-edge-faint bg-surface-secondary">
|
||||
{/* The fields as chips rather than a paragraph. A list of what you
|
||||
get is something you scan, not something you read, and a sentence
|
||||
forces the reader to parse commas to answer "is the phone number
|
||||
in there". One list per service, under the service's own name. */}
|
||||
<FactGroup
|
||||
icon={<MapPin size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
|
||||
title={t('admin.trekApi.quota.service.places')}
|
||||
note={t('admin.trekApi.includedNote')}
|
||||
>
|
||||
{fields.map(field => (
|
||||
<li key={field} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{field}</li>
|
||||
))}
|
||||
</FactGroup>
|
||||
|
||||
{/* Named as plainly as what IS included. An admin who switches the
|
||||
source expecting ratings and a photograph of every restaurant
|
||||
should find that out here and not three weeks later. */}
|
||||
<FactGroup
|
||||
icon={<X size={14} strokeWidth={2.4} className="text-content-faint" aria-hidden="true" />}
|
||||
title={t('admin.trekApi.notIncluded')}
|
||||
note={t('admin.trekApi.notIncludedNote')}
|
||||
>
|
||||
{[t('admin.trekApi.notRatings'), t('admin.trekApi.notPhotos')].map(item => (
|
||||
<li key={item} className={`${CHIP} border-dashed border-edge text-content-faint`}>{item}</li>
|
||||
))}
|
||||
</FactGroup>
|
||||
|
||||
<FactGroup
|
||||
icon={<Search size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
|
||||
title={t('admin.trekApi.quota.service.photon')}
|
||||
note={t('admin.trekApi.addressNote')}
|
||||
>
|
||||
{trekApiAddressFeatures(t).map(item => (
|
||||
<li key={item} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{item}</li>
|
||||
))}
|
||||
</FactGroup>
|
||||
|
||||
<FactGroup
|
||||
icon={<Route size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
|
||||
title={t('admin.trekApi.quota.service.valhalla')}
|
||||
note={t('admin.trekApi.routingNote')}
|
||||
>
|
||||
{trekApiRoutingFeatures(t).map(item => (
|
||||
<li key={item} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{item}</li>
|
||||
))}
|
||||
</FactGroup>
|
||||
|
||||
{/* Attribution, and not in the small print: the licences require the
|
||||
sources to be named, and naming them is also the answer to "where
|
||||
does this actually come from". */}
|
||||
<FactGroup
|
||||
icon={<Library size={14} strokeWidth={2} className="text-content-faint" aria-hidden="true" />}
|
||||
title={t('admin.trekApi.sourcesLabel')}
|
||||
note={t('admin.trekApi.sourcesNote')}
|
||||
>
|
||||
{SOURCES.map(source => (
|
||||
<li key={source} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{source}</li>
|
||||
))}
|
||||
</FactGroup>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/* The sources, named because their licences ask for it, in one line under
|
||||
the card instead of a list of every field the service answers. */}
|
||||
<p className="m-0 border-t border-edge-faint px-3.5 py-2.5 leading-snug text-content-faint" style={fs(11.5)}>
|
||||
<span className="font-semibold text-content-muted">{t('admin.trekApi.sourcesLabel')}:</span> {SOURCES.join(', ')}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,10 +21,17 @@ import { passwordErrorKey } from '../../utils/passwordError'
|
||||
* sidebar. Labels and icons stay in AdminPage; this is only the vocabulary.
|
||||
*/
|
||||
const ADMIN_TAB_IDS = [
|
||||
'users', 'defaults', 'config', 'settings', 'addons', 'plugins', 'storage',
|
||||
'users', 'defaults', 'config', 'security', 'services', 'addons', 'plugins', 'storage',
|
||||
'notifications', 'mcp-tokens', 'github', 'backup', 'audit', 'dev-notifications',
|
||||
]
|
||||
|
||||
/**
|
||||
* Tab ids that were renamed, so a bookmark, a wiki page or a mail that names the
|
||||
* old one still lands. "Settings" was one tab for sign-in, services and the
|
||||
* danger zone; it opens on security, where the danger zone went too.
|
||||
*/
|
||||
const ADMIN_TAB_ALIASES: Record<string, string> = { settings: 'security' }
|
||||
|
||||
/**
|
||||
* The ones a managed install deliberately does not offer.
|
||||
*
|
||||
@@ -64,7 +71,8 @@ export function useAdmin() {
|
||||
// on every change, so the address bar keeps saying where the reader is.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [activeTab, setActiveTabState] = useState<string>(() => {
|
||||
const asked = searchParams.get('tab')
|
||||
const raw = searchParams.get('tab')
|
||||
const asked = raw ? (ADMIN_TAB_ALIASES[raw] ?? raw) : null
|
||||
const known = [...ADMIN_TAB_IDS, ...managedAdminTabs.map(tab => tab.id)]
|
||||
return asked && known.includes(asked) ? asked : 'users'
|
||||
})
|
||||
|
||||
@@ -116,6 +116,8 @@ export default {
|
||||
subtitle: ['calc(18px * var(--fs-scale-subtitle, 1))', '1.35'],
|
||||
body: ['calc(14px * var(--fs-scale-body, 1))', '1.5'],
|
||||
caption: ['calc(12px * var(--fs-scale-caption, 1))', '1.4'],
|
||||
// Buttons and segmented controls on the settings pages: between caption and body.
|
||||
control: ['calc(13px * var(--fs-scale-body, 1))', '1.3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { settingsCardOpenKey } from '../../src/components/Settings/settingsKit';
|
||||
|
||||
/** The photo services the settings tests connect, each its own card (`photos-<id>`). */
|
||||
const PHOTO_PROVIDERS = ['immich', 'synology', 'piwigo', 'nextcloud', 'google'];
|
||||
|
||||
/** Every collapsible card of the integrations tab, by the id it is linked and remembered by. */
|
||||
export const INTEGRATION_CARDS: readonly string[] = [
|
||||
'mail-import',
|
||||
'airtrail',
|
||||
'dawarich',
|
||||
'wanderer',
|
||||
'ai-connection',
|
||||
'ai-parsing',
|
||||
'api-keys',
|
||||
'mcp',
|
||||
...PHOTO_PROVIDERS.map((id) => `photos-${id}`),
|
||||
];
|
||||
|
||||
/**
|
||||
* Leaves settings cards open, as if their reader had left them so: a test then
|
||||
* reads into a card without clicking its head first. Storage is cleared after
|
||||
* every test (tests/setup.ts), so call it in a beforeEach.
|
||||
*/
|
||||
export function openSettingsCards(ids: readonly string[] = INTEGRATION_CARDS): void {
|
||||
for (const id of ids) localStorage.setItem(settingsCardOpenKey(id), '1');
|
||||
}
|
||||
@@ -145,7 +145,7 @@ describe('MAdmin', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
|
||||
|
||||
// All standard sections are listed
|
||||
expect(screen.getByRole('button', { name: 'User Defaults' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Defaults' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Backup' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Audit' })).toBeInTheDocument();
|
||||
|
||||
@@ -156,6 +156,14 @@ describe('MAdmin', () => {
|
||||
expect(screen.queryByText('alice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-002b: a link to the desktop Security or Services tab opens the Settings section', async () => {
|
||||
for (const tab of ['security', 'services', 'settings']) {
|
||||
const { unmount } = render(<MAdmin />, { initialEntries: [`/admin?tab=${tab}`] });
|
||||
await waitFor(() => expect(screen.getByText('Authentication Methods')).toBeInTheDocument());
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-003: the back button leaves for the dashboard', async () => {
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
@@ -180,7 +188,7 @@ describe('MAdmin', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'MCP Access' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'MCP' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Dev: Notifications' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -193,7 +201,7 @@ describe('MAdmin', () => {
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
await openSection('MCP Access');
|
||||
await openSection('MCP');
|
||||
|
||||
expect(screen.getByTestId('mcp-tokens-panel')).toBeInTheDocument();
|
||||
});
|
||||
@@ -212,7 +220,7 @@ describe('MAdmin', () => {
|
||||
renderAdmin();
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
await openSection('User Defaults');
|
||||
await openSection('Defaults');
|
||||
expect(screen.getByTestId('default-user-settings')).toBeInTheDocument();
|
||||
|
||||
await openSection('Personalization');
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('MAdminMcpTokensPanel', () => {
|
||||
it('FE-MOB-MCPTOK-001: renders the section header and both sub-section titles', async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('MCP Access')).toBeInTheDocument();
|
||||
expect(screen.getByText('MCP')).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage OAuth sessions and API tokens across all users')).toBeInTheDocument();
|
||||
expect(screen.getByText('OAuth Sessions')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Tokens')).toBeInTheDocument();
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'الإشعارات',
|
||||
'admin.notifications.channels': 'القنوات',
|
||||
'admin.notifications.adminAlerts': 'تنبيهات المسؤول',
|
||||
'admin.notifications.hint': 'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.',
|
||||
'admin.notifications.none': 'معطّل',
|
||||
'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
|
||||
@@ -131,6 +133,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'فشل إنشاء رابط الدعوة',
|
||||
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
|
||||
'admin.tabs.settings': 'الإعدادات',
|
||||
'admin.tabs.security': 'الأمان',
|
||||
'admin.tabs.services': 'الخدمات',
|
||||
'admin.allowRegistration': 'السماح بالتسجيل',
|
||||
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
|
||||
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
|
||||
@@ -174,10 +178,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'مشمول',
|
||||
'admin.trekApi.notIncluded': 'غير مشمول',
|
||||
'admin.trekApi.quota.title': 'حصة اليوم',
|
||||
'admin.trekApi.quota.used': 'المستخدَم',
|
||||
'admin.trekApi.quota.remaining': 'المتبقي',
|
||||
'admin.trekApi.quota.resets': 'إعادة الضبط عند',
|
||||
'admin.trekApi.quota.limit': 'الحد',
|
||||
'admin.trekApi.quota.key': 'المفتاح',
|
||||
'admin.trekApi.quota.state.connected': 'متصل',
|
||||
'admin.trekApi.quota.state.registering': 'جارٍ الإعداد',
|
||||
@@ -297,8 +299,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'ما التالي',
|
||||
'admin.collab.whatsnext.subtitle': 'اقتراحات الأنشطة والخطوات التالية',
|
||||
'admin.tabs.config': 'التخصيص',
|
||||
'admin.tabs.defaults': 'الإعدادات الافتراضية',
|
||||
'admin.tabs.defaults': 'الافتراضيات',
|
||||
'admin.defaultSettings.title': 'إعدادات المستخدم الافتراضية',
|
||||
'admin.defaultSettings.routing': 'التوجيه',
|
||||
'admin.defaultSettings.description':
|
||||
'تعيين الإعدادات الافتراضية على مستوى النظام. سيرى المستخدمون الذين لم يغيروا إعدادًا هذه القيم. تحظى تغييراتهم دائمًا بالأولوية.',
|
||||
'admin.defaultSettings.saved': 'تم حفظ الإعداد الافتراضي',
|
||||
@@ -764,24 +767,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'تم تحديث الإضافة',
|
||||
'admin.addons.toast.error': 'فشل تحديث الإضافة',
|
||||
'admin.addons.group.count': '{enabled} من {total} مُفعّل',
|
||||
'admin.addons.search': 'البحث في الإضافات',
|
||||
'admin.addons.filter': 'عرض',
|
||||
'admin.addons.filter.all': 'الكل',
|
||||
'admin.addons.filter.on': 'مفعّلة',
|
||||
'admin.addons.filter.off': 'معطّلة',
|
||||
'admin.addons.noMatch': 'لا توجد إضافة تطابق البحث أو عامل التصفية.',
|
||||
'admin.addons.subOptions': '{count} خيار',
|
||||
'admin.addons.subOptions.zero': 'لا توجد خيارات',
|
||||
'admin.addons.subOptions.one': 'خيار واحد',
|
||||
'admin.addons.subOptions.two': 'خياران',
|
||||
'admin.addons.subOptions.few': '{count} خيارات',
|
||||
'admin.addons.subOptions.many': '{count} خيارًا',
|
||||
'admin.addons.subOptionsOn': 'المُفعّل: {count}',
|
||||
'admin.addons.subOptionsOn.zero': 'لا شيء مُفعّل',
|
||||
'admin.addons.subOptionsOn.one': 'واحد مُفعّل',
|
||||
'admin.addons.subOptionsOn.two': 'اثنان مُفعّلان',
|
||||
'admin.addons.subOptionsOn.few': 'المُفعّل: {count}',
|
||||
'admin.addons.subOptionsOn.many': 'المُفعّل: {count}',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle': 'يمكن للمستخدمين ربط خادم wanderer الخاص بهم واستيراد دروبه كجولات',
|
||||
'admin.toursHikingMap.title': 'خريطة المشي (OpenHikingMap)',
|
||||
@@ -806,7 +791,7 @@ const admin: TranslationStrings = {
|
||||
'admin.weather.requestsDesc': 'مجاني، بدون مفتاح API',
|
||||
'admin.weather.locationHint':
|
||||
'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.',
|
||||
'admin.tabs.mcpTokens': 'وصول MCP',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'وصول MCP',
|
||||
'admin.mcpTokens.subtitle': 'إدارة جلسات OAuth ورموز API لجميع المستخدمين',
|
||||
'admin.mcpTokens.sectionTitle': 'رموز API',
|
||||
@@ -952,7 +937,6 @@ const admin: TranslationStrings = {
|
||||
'يُستخدم لكل مستخدم لم يُدخل مفتاحه الخاص، حتى يحصل الخادم بأكمله على بلاطات CARTO بدون علامة مائية. يُخزَّن مشفّرًا.',
|
||||
'admin.group.users': 'المستخدمون',
|
||||
'admin.group.config': 'الإعدادات',
|
||||
'admin.group.integration': 'التكاملات',
|
||||
'admin.group.maintenance': 'الصيانة',
|
||||
'admin.invite.tripLabel': 'إضافة إلى رحلة (اختياري)',
|
||||
'admin.invite.tripNone': 'بدون رحلة',
|
||||
|
||||
@@ -37,7 +37,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': 'أدخل منفذاً بين 1 و65535.',
|
||||
'mailAdmin.problem.templateToken': 'يحتاج العنوان إلى {token} في موضع رمز كل شخص.',
|
||||
'mailAdmin.problem.dailyInvalid': 'أدخل عدداً صحيحاً من 0 إلى 1000.',
|
||||
'mailAdmin.status.title': 'الحالة',
|
||||
'mailAdmin.status.lastRead': 'آخر قراءة {time}',
|
||||
'mailAdmin.status.never': 'أبداً',
|
||||
'mailAdmin.status.mails': 'الرسائل، 24 ساعة',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Bildirişlər',
|
||||
'admin.notifications.channels': 'Kanallar',
|
||||
'admin.notifications.adminAlerts': 'Admin bildirişləri',
|
||||
'admin.notifications.hint': 'Bir bildiriş kanalı seçin. Eyni anda yalnız biri aktiv ola bilər.',
|
||||
'admin.notifications.none': 'Deaktivdir',
|
||||
'admin.notifications.email': 'E-poçt (SMTP)',
|
||||
@@ -130,6 +132,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'Dəvət keçidi yaratmaq mümkün olmadı',
|
||||
'admin.invite.deleteError': 'Dəvət keçidini silmək mümkün olmadı',
|
||||
'admin.tabs.settings': 'Tənzimləmələr',
|
||||
'admin.tabs.security': 'Təhlükəsizlik',
|
||||
'admin.tabs.services': 'Xidmətlər',
|
||||
'admin.allowRegistration': 'Qeydiyyata icazə ver',
|
||||
'admin.allowRegistrationHint': 'Yeni istifadəçilər özləri qeydiyyatdan keçə bilərlər',
|
||||
'admin.authMethods': 'Doğrulama üsulları',
|
||||
@@ -191,10 +195,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Daxildir',
|
||||
'admin.trekApi.notIncluded': 'Daxil deyil',
|
||||
'admin.trekApi.quota.title': 'Bugünkü limit',
|
||||
'admin.trekApi.quota.used': 'İstifadə edilib',
|
||||
'admin.trekApi.quota.remaining': 'Qalıb',
|
||||
'admin.trekApi.quota.resets': 'Sıfırlanma vaxtı',
|
||||
'admin.trekApi.quota.limit': 'Gündəlik limit',
|
||||
'admin.trekApi.quota.key': 'Açar',
|
||||
'admin.trekApi.quota.state.connected': 'Qoşulub',
|
||||
'admin.trekApi.quota.state.registering': 'Qurulur',
|
||||
@@ -320,8 +322,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'Növbədə nə var',
|
||||
'admin.collab.whatsnext.subtitle': 'Fəaliyyət təklifləri və növbəti addımlar',
|
||||
'admin.tabs.config': 'Fərdiləşdirmə',
|
||||
'admin.tabs.defaults': 'İstifadəçi standartları',
|
||||
'admin.tabs.defaults': 'Defoltlar',
|
||||
'admin.defaultSettings.title': 'Standart istifadəçi tənzimləmələri',
|
||||
'admin.defaultSettings.routing': 'Marşrutlar',
|
||||
'admin.defaultSettings.description':
|
||||
'Bütün sistem üçün standart dəyərləri təyin edin. Tənzimləməni dəyişməmiş istifadəçilər bu dəyərləri görəcəklər. Onların öz dəyişiklikləri həmişə üstün tutulur.',
|
||||
'admin.defaultSettings.saved': 'Standart dəyər yadda saxlanıldı',
|
||||
@@ -809,16 +812,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Əlavə yeniləndi',
|
||||
'admin.addons.toast.error': 'Əlavəni yeniləmək mümkün olmadı',
|
||||
'admin.addons.group.count': '{total} əlavədən {enabled} aktivdir',
|
||||
'admin.addons.search': 'Əlavələrdə axtar',
|
||||
'admin.addons.filter': 'Göstər',
|
||||
'admin.addons.filter.all': 'Hamısı',
|
||||
'admin.addons.filter.on': 'Aktiv',
|
||||
'admin.addons.filter.off': 'Deaktiv',
|
||||
'admin.addons.noMatch': 'Axtarışa və ya filtrə uyğun əlavə yoxdur.',
|
||||
'admin.addons.subOptions': '{count} seçim',
|
||||
'admin.addons.subOptions.one': '{count} seçim',
|
||||
'admin.addons.subOptionsOn': '{count} aktiv',
|
||||
'admin.addons.subOptionsOn.one': '{count} aktiv',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle':
|
||||
'İstifadəçilər öz wanderer instansiyalarını qoşa və onun cığırlarını tur kimi idxal edə bilərlər',
|
||||
@@ -844,7 +837,7 @@ const admin: TranslationStrings = {
|
||||
'admin.weather.requestsDesc': 'Pulsuzdur, API açarı tələb olunmur',
|
||||
'admin.weather.locationHint':
|
||||
'Hava məlumatları hər gün üçün koordinatları olan ilk məkana əsaslanır. Günə heç bir məkan təyin edilməyibsə, məkan siyahısındakı istənilən məkan istinad kimi istifadə olunur.',
|
||||
'admin.tabs.mcpTokens': 'MCP girişi',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'MCP girişi',
|
||||
'admin.mcpTokens.subtitle': 'Bütün istifadəçilər üzrə OAuth sessiyalarını və API tokenlərini idarə edin',
|
||||
'admin.mcpTokens.sectionTitle': 'API tokenləri',
|
||||
@@ -949,7 +942,6 @@ const admin: TranslationStrings = {
|
||||
'admin.passkey.resetDone.one': '{count} keçid açarı silindi',
|
||||
'admin.group.users': 'İstifadəçilər',
|
||||
'admin.group.config': 'Konfiqurasiya',
|
||||
'admin.group.integration': 'İnteqrasiyalar',
|
||||
'admin.group.maintenance': 'Texniki xidmət',
|
||||
'admin.invite.tripLabel': 'Səyahətə əlavə et (istəyə bağlı)',
|
||||
'admin.invite.tripNone': 'Səyahət yoxdur',
|
||||
|
||||
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': '1 ilə 65535 arasında port daxil et.',
|
||||
'mailAdmin.problem.templateToken': 'Ünvanda hər şəxsin kodu üçün {token} olmalıdır.',
|
||||
'mailAdmin.problem.dailyInvalid': '0 ilə 1000 arasında tam ədəd daxil et.',
|
||||
'mailAdmin.status.title': 'Status', // same-as-en
|
||||
'mailAdmin.status.lastRead': 'Son oxuma {time}',
|
||||
'mailAdmin.status.never': 'heç vaxt',
|
||||
'mailAdmin.status.mails': 'Məktublar, 24 saat',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Notificações',
|
||||
'admin.notifications.channels': 'Canais',
|
||||
'admin.notifications.adminAlerts': 'Alertas do admin',
|
||||
'admin.notifications.hint': 'Escolha um canal de notificação. Apenas um pode estar ativo por vez.',
|
||||
'admin.notifications.none': 'Desativado',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
@@ -74,6 +76,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'Falha ao criar link de convite',
|
||||
'admin.invite.deleteError': 'Falha ao excluir link de convite',
|
||||
'admin.tabs.settings': 'Configurações',
|
||||
'admin.tabs.security': 'Segurança',
|
||||
'admin.tabs.services': 'Serviços',
|
||||
'admin.allowRegistration': 'Permitir cadastro',
|
||||
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
|
||||
'admin.authMethods': 'Métodos de autenticação',
|
||||
@@ -135,10 +139,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Incluído',
|
||||
'admin.trekApi.notIncluded': 'Não incluído',
|
||||
'admin.trekApi.quota.title': 'Cota de hoje',
|
||||
'admin.trekApi.quota.used': 'Usado',
|
||||
'admin.trekApi.quota.remaining': 'Restante',
|
||||
'admin.trekApi.quota.resets': 'Reinicia às',
|
||||
'admin.trekApi.quota.limit': 'Limite',
|
||||
'admin.trekApi.quota.key': 'Chave',
|
||||
'admin.trekApi.quota.state.connected': 'Conectado',
|
||||
'admin.trekApi.quota.state.registering': 'Configurando',
|
||||
@@ -261,8 +263,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'Próximos passos',
|
||||
'admin.collab.whatsnext.subtitle': 'Sugestões de atividades e próximos passos',
|
||||
'admin.tabs.config': 'Personalização',
|
||||
'admin.tabs.defaults': 'Padrões do usuário',
|
||||
'admin.tabs.defaults': 'Padrões',
|
||||
'admin.defaultSettings.title': 'Configurações padrão do usuário',
|
||||
'admin.defaultSettings.routing': 'Rotas',
|
||||
'admin.defaultSettings.description':
|
||||
'Defina padrões para toda a instância. Usuários que não alteraram uma configuração verão esses valores. As próprias alterações deles sempre têm prioridade.',
|
||||
'admin.defaultSettings.saved': 'Padrão salvo',
|
||||
@@ -718,16 +721,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Complemento atualizado',
|
||||
'admin.addons.toast.error': 'Falha ao atualizar complemento',
|
||||
'admin.addons.group.count': '{enabled} de {total} ativados',
|
||||
'admin.addons.search': 'Pesquisar complementos',
|
||||
'admin.addons.filter': 'Mostrar',
|
||||
'admin.addons.filter.all': 'Todos',
|
||||
'admin.addons.filter.on': 'Ativos',
|
||||
'admin.addons.filter.off': 'Inativos',
|
||||
'admin.addons.noMatch': 'Nenhum complemento corresponde à pesquisa ou ao filtro.',
|
||||
'admin.addons.subOptions': '{count} opções',
|
||||
'admin.addons.subOptions.one': '{count} opção',
|
||||
'admin.addons.subOptionsOn': '{count} ativas',
|
||||
'admin.addons.subOptionsOn.one': '{count} ativa',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle':
|
||||
'Os usuários podem conectar a própria instância do wanderer e importar as trilhas dela como passeios',
|
||||
@@ -802,7 +795,7 @@ const admin: TranslationStrings = {
|
||||
'admin.update.wikiLink': 'Abrir o guia de atualização',
|
||||
'admin.update.reloadHint': 'Recarregue a página em alguns segundos.',
|
||||
'admin.tabs.permissions': 'Permissões',
|
||||
'admin.tabs.mcpTokens': 'Acesso MCP',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'Acesso MCP',
|
||||
'admin.mcpTokens.subtitle': 'Gerenciar sessões OAuth e tokens de API de todos os usuários',
|
||||
'admin.mcpTokens.sectionTitle': 'Tokens de API',
|
||||
@@ -924,7 +917,6 @@ const admin: TranslationStrings = {
|
||||
"Usada para todos os usuários que não inseriram a própria chave, assim toda a instância recebe blocos do CARTO sem marca d'água. Armazenada de forma criptografada.",
|
||||
'admin.group.users': 'Usuários',
|
||||
'admin.group.config': 'Configuração',
|
||||
'admin.group.integration': 'Integrações',
|
||||
'admin.group.maintenance': 'Manutenção',
|
||||
'admin.invite.tripLabel': 'Adicionar à viagem (opcional)',
|
||||
'admin.invite.tripNone': 'Nenhuma viagem',
|
||||
|
||||
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': 'Digite uma porta entre 1 e 65535.',
|
||||
'mailAdmin.problem.templateToken': 'O endereço precisa de {token} para o código de cada pessoa.',
|
||||
'mailAdmin.problem.dailyInvalid': 'Digite um número inteiro de 0 a 1000.',
|
||||
'mailAdmin.status.title': 'Status', // same-as-en
|
||||
'mailAdmin.status.lastRead': 'Última leitura {time}',
|
||||
'mailAdmin.status.never': 'nunca',
|
||||
'mailAdmin.status.mails': 'E-mails, 24 h',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Notificacions',
|
||||
'admin.notifications.channels': 'Canals',
|
||||
'admin.notifications.adminAlerts': 'Avisos d’administració',
|
||||
'admin.notifications.hint': 'Tria un canal per rebre notificacions. Només un pot estar actiu alhora.',
|
||||
'admin.notifications.none': 'Desactivat',
|
||||
'admin.notifications.email': 'Correu (SMTP)',
|
||||
@@ -80,6 +82,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': "Error en crear l'enllaç",
|
||||
'admin.invite.deleteError': "Error en eliminar l'enllaç",
|
||||
'admin.tabs.settings': 'Configuració',
|
||||
'admin.tabs.security': 'Seguretat',
|
||||
'admin.tabs.services': 'Serveis',
|
||||
'admin.allowRegistration': 'Permetre el registre',
|
||||
'admin.allowRegistrationHint': 'Els nous usuaris es poden registrar per si mateixos',
|
||||
'admin.authMethods': "Mètodes d'autenticació",
|
||||
@@ -141,10 +145,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Inclòs',
|
||||
'admin.trekApi.notIncluded': 'No inclòs',
|
||||
'admin.trekApi.quota.title': "Quota d'avui",
|
||||
'admin.trekApi.quota.used': 'Utilitzat',
|
||||
'admin.trekApi.quota.remaining': 'Restant',
|
||||
'admin.trekApi.quota.resets': 'Es reinicia a les',
|
||||
'admin.trekApi.quota.limit': 'Límit',
|
||||
'admin.trekApi.quota.key': 'Clau',
|
||||
'admin.trekApi.quota.state.connected': 'Connectat',
|
||||
'admin.trekApi.quota.state.registering': "S'està configurant",
|
||||
@@ -250,8 +252,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'Què ve després',
|
||||
'admin.collab.whatsnext.subtitle': "Suggeriments d'activitats i propers passos",
|
||||
'admin.tabs.config': 'Personalització',
|
||||
'admin.tabs.defaults': 'Valors per defecte',
|
||||
'admin.tabs.defaults': 'Predeterminats',
|
||||
'admin.defaultSettings.title': 'Configuració per defecte dels usuaris',
|
||||
'admin.defaultSettings.routing': 'Rutes',
|
||||
'admin.defaultSettings.description':
|
||||
'Estableix valors per defecte per a tota la instància. Els usuaris que no hagin canviat una opció veuran aquests valors. Els seus propis canvis sempre tenen prioritat.',
|
||||
'admin.defaultSettings.saved': 'Per defecte desat',
|
||||
@@ -314,16 +317,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Complement actualitzat',
|
||||
'admin.addons.toast.error': "No s'ha pogut actualitzar el complement",
|
||||
'admin.addons.group.count': '{enabled} de {total} actius',
|
||||
'admin.addons.search': 'Cerca complements',
|
||||
'admin.addons.filter': 'Mostra',
|
||||
'admin.addons.filter.all': 'Tots',
|
||||
'admin.addons.filter.on': 'Actius',
|
||||
'admin.addons.filter.off': 'Inactius',
|
||||
'admin.addons.noMatch': 'Cap complement coincideix amb la cerca o el filtre.',
|
||||
'admin.addons.subOptions': '{count} opcions',
|
||||
'admin.addons.subOptions.one': '{count} opció',
|
||||
'admin.addons.subOptionsOn': '{count} actives',
|
||||
'admin.addons.subOptionsOn.one': '{count} activa',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle':
|
||||
'Els usuaris poden connectar la seva pròpia instància de wanderer i importar-ne els senders com a excursions',
|
||||
@@ -349,7 +342,7 @@ const admin: TranslationStrings = {
|
||||
'admin.weather.requestsDesc': 'Gratuït, sense necessitat de clau API',
|
||||
'admin.weather.locationHint':
|
||||
'El temps es basa en el primer lloc amb coordenades de cada dia. Si no hi ha cap lloc assignat a un dia, es fa servir com a referència qualsevol lloc de la llista.',
|
||||
'admin.tabs.mcpTokens': 'Accés MCP',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'Accés MCP',
|
||||
'admin.mcpTokens.subtitle': "Gestionar sessions OAuth i tokens d'API de tots els usuaris",
|
||||
'admin.mcpTokens.sectionTitle': "Tokens d'API",
|
||||
@@ -453,7 +446,6 @@ const admin: TranslationStrings = {
|
||||
'admin.tabs.permissions': 'Permisos',
|
||||
'admin.group.users': 'Usuaris',
|
||||
'admin.group.config': 'Configuració',
|
||||
'admin.group.integration': 'Integracions',
|
||||
'admin.group.maintenance': 'Manteniment',
|
||||
'admin.notifications.emailPanel.title': 'Correu (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
|
||||
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': 'Introdueix un port entre 1 i 65535.',
|
||||
'mailAdmin.problem.templateToken': 'L’adreça necessita {token} per al codi de cada persona.',
|
||||
'mailAdmin.problem.dailyInvalid': 'Introdueix un nombre enter de 0 a 1000.',
|
||||
'mailAdmin.status.title': 'Estat',
|
||||
'mailAdmin.status.lastRead': 'Darrera lectura {time}',
|
||||
'mailAdmin.status.never': 'mai',
|
||||
'mailAdmin.status.mails': 'Correus, 24 h',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Oznámení',
|
||||
'admin.notifications.channels': 'Kanály',
|
||||
'admin.notifications.adminAlerts': 'Upozornění pro správce',
|
||||
'admin.notifications.hint': 'Vyberte kanál oznámení. Současně může být aktivní pouze jeden.',
|
||||
'admin.notifications.none': 'Vypnuto',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
@@ -78,6 +80,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'Nepodařilo se vytvořit pozvánku',
|
||||
'admin.invite.deleteError': 'Nepodařilo se smazat pozvánku',
|
||||
'admin.tabs.settings': 'Nastavení',
|
||||
'admin.tabs.security': 'Zabezpečení',
|
||||
'admin.tabs.services': 'Služby',
|
||||
'admin.allowRegistration': 'Povolit registraci',
|
||||
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
|
||||
'admin.authMethods': 'Metody ověřování',
|
||||
@@ -136,10 +140,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Obsahuje',
|
||||
'admin.trekApi.notIncluded': 'Neobsahuje',
|
||||
'admin.trekApi.quota.title': 'Dnešní limit',
|
||||
'admin.trekApi.quota.used': 'Využito',
|
||||
'admin.trekApi.quota.remaining': 'Zbývá',
|
||||
'admin.trekApi.quota.resets': 'Obnoví se v',
|
||||
'admin.trekApi.quota.limit': 'Denní limit',
|
||||
'admin.trekApi.quota.key': 'Klíč',
|
||||
'admin.trekApi.quota.state.connected': 'Připojeno',
|
||||
'admin.trekApi.quota.state.registering': 'Nastavuje se',
|
||||
@@ -262,8 +264,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'Co dál',
|
||||
'admin.collab.whatsnext.subtitle': 'Návrhy aktivit a další kroky',
|
||||
'admin.tabs.config': 'Personalizace',
|
||||
'admin.tabs.defaults': 'Výchozí nastavení uživatele',
|
||||
'admin.tabs.defaults': 'Výchozí',
|
||||
'admin.defaultSettings.title': 'Výchozí nastavení uživatele',
|
||||
'admin.defaultSettings.routing': 'Trasy',
|
||||
'admin.defaultSettings.description':
|
||||
'Nastavte výchozí hodnoty pro celou instanci. Uživatelé, kteří nezměnili nastavení, uvidí tyto hodnoty. Jejich vlastní změny mají vždy přednost.',
|
||||
'admin.defaultSettings.saved': 'Výchozí nastavení uloženo',
|
||||
@@ -717,18 +720,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Doplněk byl aktualizován',
|
||||
'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila',
|
||||
'admin.addons.group.count': '{enabled} z {total} zapnuto',
|
||||
'admin.addons.search': 'Hledat doplňky',
|
||||
'admin.addons.filter': 'Zobrazit',
|
||||
'admin.addons.filter.all': 'Vše',
|
||||
'admin.addons.filter.on': 'Zapnuté',
|
||||
'admin.addons.filter.off': 'Vypnuté',
|
||||
'admin.addons.noMatch': 'Hledání ani filtru neodpovídá žádný doplněk.',
|
||||
'admin.addons.subOptions': '{count} možností',
|
||||
'admin.addons.subOptions.one': '{count} možnost',
|
||||
'admin.addons.subOptions.few': '{count} možnosti',
|
||||
'admin.addons.subOptionsOn': 'zapnuto: {count}',
|
||||
'admin.addons.subOptionsOn.one': 'zapnuto: {count}',
|
||||
'admin.addons.subOptionsOn.few': 'zapnuto: {count}',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle':
|
||||
'Uživatelé mohou připojit vlastní instanci wanderer a importovat z ní trasy jako výlety',
|
||||
@@ -760,7 +751,7 @@ const admin: TranslationStrings = {
|
||||
'admin.audit.col.resource': 'Zdroj',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Detaily',
|
||||
'admin.tabs.mcpTokens': 'MCP přístup',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'MCP přístup',
|
||||
'admin.mcpTokens.subtitle': 'Správa OAuth relací a API tokenů všech uživatelů',
|
||||
'admin.mcpTokens.sectionTitle': 'API tokeny',
|
||||
@@ -928,7 +919,6 @@ const admin: TranslationStrings = {
|
||||
'Použije se pro každého uživatele, který nezadal vlastní klíč, takže celá instance získá dlaždice CARTO bez vodoznaku. Ukládá se šifrovaně.',
|
||||
'admin.group.users': 'Uživatelé',
|
||||
'admin.group.config': 'Konfigurace',
|
||||
'admin.group.integration': 'Integrace',
|
||||
'admin.group.maintenance': 'Údržba',
|
||||
'admin.invite.tripLabel': 'Přidat k cestě (volitelné)',
|
||||
'admin.invite.tripNone': 'Žádná cesta',
|
||||
|
||||
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': 'Zadej port mezi 1 a 65535.',
|
||||
'mailAdmin.problem.templateToken': 'Adresa musí obsahovat {token} pro kód každého uživatele.',
|
||||
'mailAdmin.problem.dailyInvalid': 'Zadej celé číslo od 0 do 1000.',
|
||||
'mailAdmin.status.title': 'Stav',
|
||||
'mailAdmin.status.lastRead': 'Naposledy přečteno {time}',
|
||||
'mailAdmin.status.never': 'nikdy',
|
||||
'mailAdmin.status.mails': 'E-maily, 24 h',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Benachrichtigungen',
|
||||
'admin.notifications.channels': 'Kanäle',
|
||||
'admin.notifications.adminAlerts': 'Admin-Meldungen',
|
||||
'admin.notifications.hint': 'Wählen Sie einen Benachrichtigungskanal. Es kann nur einer gleichzeitig aktiv sein.',
|
||||
'admin.notifications.none': 'Deaktiviert',
|
||||
'admin.notifications.email': 'E-Mail (SMTP)',
|
||||
@@ -75,6 +77,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'Fehler beim Erstellen des Einladungslinks',
|
||||
'admin.invite.deleteError': 'Fehler beim Löschen des Einladungslinks',
|
||||
'admin.tabs.settings': 'Einstellungen',
|
||||
'admin.tabs.security': 'Sicherheit',
|
||||
'admin.tabs.services': 'Dienste',
|
||||
'admin.allowRegistration': 'Registrierung erlauben',
|
||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||
'admin.authMethods': 'Anmeldemethoden',
|
||||
@@ -137,10 +141,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Enthalten',
|
||||
'admin.trekApi.notIncluded': 'Nicht enthalten',
|
||||
'admin.trekApi.quota.title': 'Kontingent heute',
|
||||
'admin.trekApi.quota.used': 'Verbraucht',
|
||||
'admin.trekApi.quota.remaining': 'Verbleibend',
|
||||
'admin.trekApi.quota.resets': 'Zurückgesetzt um',
|
||||
'admin.trekApi.quota.limit': 'Tageslimit',
|
||||
'admin.trekApi.quota.key': 'Schlüssel',
|
||||
'admin.trekApi.quota.state.connected': 'Verbunden',
|
||||
'admin.trekApi.quota.state.registering': 'Wird eingerichtet',
|
||||
@@ -265,8 +267,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'Was kommt als Nächstes',
|
||||
'admin.collab.whatsnext.subtitle': 'Aktivitätsvorschläge und nächste Schritte',
|
||||
'admin.tabs.config': 'Personalisierung',
|
||||
'admin.tabs.defaults': 'Benutzer-Standards',
|
||||
'admin.tabs.defaults': 'Standards',
|
||||
'admin.defaultSettings.title': 'Standard-Benutzereinstellungen',
|
||||
'admin.defaultSettings.routing': 'Routing',
|
||||
'admin.defaultSettings.description':
|
||||
'Instanzweite Standards festlegen. Benutzer, die eine Einstellung nicht geändert haben, sehen diese Werte. Eigene Änderungen haben immer Vorrang.',
|
||||
'admin.defaultSettings.saved': 'Standard gespeichert',
|
||||
@@ -727,16 +730,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||
'admin.addons.group.count': '{enabled} von {total} aktiviert',
|
||||
'admin.addons.search': 'Addons durchsuchen',
|
||||
'admin.addons.filter': 'Anzeigen',
|
||||
'admin.addons.filter.all': 'Alle',
|
||||
'admin.addons.filter.on': 'An',
|
||||
'admin.addons.filter.off': 'Aus',
|
||||
'admin.addons.noMatch': 'Kein Addon passt zur Suche oder zum Filter.',
|
||||
'admin.addons.subOptions': '{count} Optionen',
|
||||
'admin.addons.subOptions.one': '{count} Option',
|
||||
'admin.addons.subOptionsOn': '{count} aktiv',
|
||||
'admin.addons.subOptionsOn.one': '{count} aktiv',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle':
|
||||
'Nutzer können ihre eigene wanderer-Instanz verbinden und deren Trails als Touren importieren',
|
||||
@@ -762,7 +755,7 @@ const admin: TranslationStrings = {
|
||||
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
|
||||
'admin.weather.locationHint':
|
||||
'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
|
||||
'admin.tabs.mcpTokens': 'MCP-Zugang',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'MCP-Zugang',
|
||||
'admin.mcpTokens.subtitle': 'OAuth-Sitzungen und API-Tokens aller Benutzer verwalten',
|
||||
'admin.mcpTokens.sectionTitle': 'API-Tokens',
|
||||
@@ -933,7 +926,6 @@ const admin: TranslationStrings = {
|
||||
'Wird für jeden Nutzer verwendet, der keinen eigenen Key eingetragen hat, damit die ganze Instanz CARTO-Kacheln ohne Wasserzeichen bekommt. Verschlüsselt gespeichert.',
|
||||
'admin.group.users': 'Benutzer',
|
||||
'admin.group.config': 'Konfiguration',
|
||||
'admin.group.integration': 'Integrationen',
|
||||
'admin.group.maintenance': 'Wartung',
|
||||
'admin.invite.tripLabel': 'Zu Trip hinzufügen (optional)',
|
||||
'admin.invite.tripNone': 'Kein Trip',
|
||||
|
||||
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': 'Gib einen Port zwischen 1 und 65535 ein.',
|
||||
'mailAdmin.problem.templateToken': 'Die Adresse braucht {token} für den persönlichen Code.',
|
||||
'mailAdmin.problem.dailyInvalid': 'Gib eine ganze Zahl von 0 bis 1000 ein.',
|
||||
'mailAdmin.status.title': 'Status', // same-as-en
|
||||
'mailAdmin.status.lastRead': 'Zuletzt gelesen {time}',
|
||||
'mailAdmin.status.never': 'noch nie',
|
||||
'mailAdmin.status.mails': 'Mails, 24 Std.',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Notifications',
|
||||
'admin.notifications.channels': 'Channels',
|
||||
'admin.notifications.adminAlerts': 'Admin alerts',
|
||||
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||
'admin.notifications.none': 'Disabled',
|
||||
'admin.notifications.email': 'Email (SMTP)',
|
||||
@@ -126,6 +128,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'Failed to create invite link',
|
||||
'admin.invite.deleteError': 'Failed to delete invite link',
|
||||
'admin.tabs.settings': 'Settings',
|
||||
'admin.tabs.security': 'Security',
|
||||
'admin.tabs.services': 'Services',
|
||||
'admin.allowRegistration': 'Allow Registration',
|
||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||
'admin.authMethods': 'Authentication Methods',
|
||||
@@ -186,10 +190,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Included',
|
||||
'admin.trekApi.notIncluded': 'Not included',
|
||||
'admin.trekApi.quota.title': "Today's allowance",
|
||||
'admin.trekApi.quota.used': 'Used',
|
||||
'admin.trekApi.quota.remaining': 'Remaining',
|
||||
'admin.trekApi.quota.resets': 'Resets at',
|
||||
'admin.trekApi.quota.limit': 'Daily limit',
|
||||
'admin.trekApi.quota.key': 'Key',
|
||||
'admin.trekApi.quota.state.connected': 'Connected',
|
||||
'admin.trekApi.quota.state.registering': 'Setting up',
|
||||
@@ -311,8 +313,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': "What's Next",
|
||||
'admin.collab.whatsnext.subtitle': 'Activity suggestions and next steps',
|
||||
'admin.tabs.config': 'Personalization',
|
||||
'admin.tabs.defaults': 'User Defaults',
|
||||
'admin.tabs.defaults': 'Defaults',
|
||||
'admin.defaultSettings.title': 'Default User Settings',
|
||||
'admin.defaultSettings.routing': 'Routing',
|
||||
'admin.defaultSettings.description':
|
||||
'Set instance-wide defaults. Users who have not changed a setting will see these values. Their own changes always take priority.',
|
||||
'admin.defaultSettings.saved': 'Default saved',
|
||||
@@ -770,16 +773,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Addon updated',
|
||||
'admin.addons.toast.error': 'Failed to update addon',
|
||||
'admin.addons.group.count': '{enabled} of {total} enabled',
|
||||
'admin.addons.search': 'Search addons',
|
||||
'admin.addons.filter': 'Show',
|
||||
'admin.addons.filter.all': 'All',
|
||||
'admin.addons.filter.on': 'On',
|
||||
'admin.addons.filter.off': 'Off',
|
||||
'admin.addons.noMatch': 'No addon matches the search or the filter.',
|
||||
'admin.addons.subOptions': '{count} options',
|
||||
'admin.addons.subOptions.one': '{count} option',
|
||||
'admin.addons.subOptionsOn': '{count} on',
|
||||
'admin.addons.subOptionsOn.one': '{count} on',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle': 'Users can connect their own wanderer instance and import its trails as tours',
|
||||
'admin.toursHikingMap.title': 'Hiking map (OpenHikingMap)',
|
||||
@@ -804,7 +797,7 @@ const admin: TranslationStrings = {
|
||||
'admin.weather.requestsDesc': 'Free, no API key required',
|
||||
'admin.weather.locationHint':
|
||||
'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
||||
'admin.tabs.mcpTokens': 'MCP Access',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'MCP Access',
|
||||
'admin.mcpTokens.subtitle': 'Manage OAuth sessions and API tokens across all users',
|
||||
'admin.mcpTokens.sectionTitle': 'API Tokens',
|
||||
@@ -904,7 +897,6 @@ const admin: TranslationStrings = {
|
||||
'admin.passkey.resetDone.one': 'Removed {count} passkey',
|
||||
'admin.group.users': 'Users',
|
||||
'admin.group.config': 'Configuration',
|
||||
'admin.group.integration': 'Integrations',
|
||||
'admin.group.maintenance': 'Maintenance',
|
||||
'admin.invite.tripLabel': 'Add to trip (optional)',
|
||||
'admin.invite.tripNone': 'No trip',
|
||||
|
||||
@@ -38,7 +38,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': 'Enter a port between 1 and 65535.',
|
||||
'mailAdmin.problem.templateToken': 'The address needs {token} for each person’s code.',
|
||||
'mailAdmin.problem.dailyInvalid': 'Enter a whole number from 0 to 1000.',
|
||||
'mailAdmin.status.title': 'Status',
|
||||
'mailAdmin.status.lastRead': 'Last read {time}',
|
||||
'mailAdmin.status.never': 'never',
|
||||
'mailAdmin.status.mails': 'Mails, 24 h',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Notificaciones',
|
||||
'admin.notifications.channels': 'Canales',
|
||||
'admin.notifications.adminAlerts': 'Avisos de administración',
|
||||
'admin.notifications.hint': 'Elija un canal de notificación. Solo uno puede estar activo a la vez.',
|
||||
'admin.notifications.none': 'Desactivado',
|
||||
'admin.notifications.email': 'Correo (SMTP)',
|
||||
@@ -21,7 +23,7 @@ const admin: TranslationStrings = {
|
||||
'admin.subtitle': 'Gestión de usuarios y ajustes del sistema',
|
||||
'admin.tabs.users': 'Usuarios',
|
||||
'admin.tabs.categories': 'Categorías',
|
||||
'admin.tabs.backup': 'Copia de seguridad',
|
||||
'admin.tabs.backup': 'Respaldo',
|
||||
'admin.tabs.audit': 'Auditoría',
|
||||
'admin.stats.users': 'Usuarios',
|
||||
'admin.stats.users.one': 'Usuario',
|
||||
@@ -75,6 +77,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'Error al crear el enlace',
|
||||
'admin.invite.deleteError': 'Error al eliminar el enlace',
|
||||
'admin.tabs.settings': 'Ajustes',
|
||||
'admin.tabs.security': 'Seguridad',
|
||||
'admin.tabs.services': 'Servicios',
|
||||
'admin.allowRegistration': 'Permitir el registro',
|
||||
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
|
||||
'admin.authMethods': 'Métodos de autenticación',
|
||||
@@ -136,10 +140,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Incluido',
|
||||
'admin.trekApi.notIncluded': 'No incluido',
|
||||
'admin.trekApi.quota.title': 'Cupo de hoy',
|
||||
'admin.trekApi.quota.used': 'Usado',
|
||||
'admin.trekApi.quota.remaining': 'Restante',
|
||||
'admin.trekApi.quota.resets': 'Se reinicia a las',
|
||||
'admin.trekApi.quota.limit': 'Límite',
|
||||
'admin.trekApi.quota.key': 'Clave',
|
||||
'admin.trekApi.quota.state.connected': 'Conectado',
|
||||
'admin.trekApi.quota.state.registering': 'Configurando',
|
||||
@@ -262,8 +264,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'Qué sigue',
|
||||
'admin.collab.whatsnext.subtitle': 'Sugerencias de actividades y próximos pasos',
|
||||
'admin.tabs.config': 'Personalización',
|
||||
'admin.tabs.defaults': 'Valores predeterminados',
|
||||
'admin.tabs.defaults': 'Predeterminados',
|
||||
'admin.defaultSettings.title': 'Configuración predeterminada de usuarios',
|
||||
'admin.defaultSettings.routing': 'Rutas',
|
||||
'admin.defaultSettings.description':
|
||||
'Establece valores predeterminados para toda la instancia. Los usuarios que no hayan cambiado una opción verán estos valores. Sus propios cambios siempre tienen prioridad.',
|
||||
'admin.defaultSettings.saved': 'Predeterminado guardado',
|
||||
@@ -706,16 +709,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Complemento actualizado',
|
||||
'admin.addons.toast.error': 'No se pudo actualizar el complemento',
|
||||
'admin.addons.group.count': '{enabled} de {total} activados',
|
||||
'admin.addons.search': 'Buscar complementos',
|
||||
'admin.addons.filter': 'Mostrar',
|
||||
'admin.addons.filter.all': 'Todos',
|
||||
'admin.addons.filter.on': 'Activos',
|
||||
'admin.addons.filter.off': 'Inactivos',
|
||||
'admin.addons.noMatch': 'Ningún complemento coincide con la búsqueda o el filtro.',
|
||||
'admin.addons.subOptions': '{count} opciones',
|
||||
'admin.addons.subOptions.one': '{count} opción',
|
||||
'admin.addons.subOptionsOn': '{count} activas',
|
||||
'admin.addons.subOptionsOn.one': '{count} activa',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle':
|
||||
'Los usuarios pueden conectar su propia instancia de wanderer e importar sus senderos como excursiones',
|
||||
@@ -741,7 +734,7 @@ const admin: TranslationStrings = {
|
||||
'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API',
|
||||
'admin.weather.locationHint':
|
||||
'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
|
||||
'admin.tabs.mcpTokens': 'Acceso MCP',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'Acceso MCP',
|
||||
'admin.mcpTokens.subtitle': 'Gestionar sesiones OAuth y tokens de API de todos los usuarios',
|
||||
'admin.mcpTokens.sectionTitle': 'Tokens de API',
|
||||
@@ -940,7 +933,6 @@ const admin: TranslationStrings = {
|
||||
'Se usa para cada usuario que no haya introducido su propia clave, de modo que toda la instancia obtenga teselas de CARTO sin marca de agua. Se almacena cifrada.',
|
||||
'admin.group.users': 'Usuarios',
|
||||
'admin.group.config': 'Configuración',
|
||||
'admin.group.integration': 'Integraciones',
|
||||
'admin.group.maintenance': 'Mantenimiento',
|
||||
'admin.invite.tripLabel': 'Añadir a un viaje (opcional)',
|
||||
'admin.invite.tripNone': 'Sin viaje',
|
||||
|
||||
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': 'Introduce un puerto entre 1 y 65535.',
|
||||
'mailAdmin.problem.templateToken': 'La dirección necesita {token} para el código de cada persona.',
|
||||
'mailAdmin.problem.dailyInvalid': 'Introduce un número entero de 0 a 1000.',
|
||||
'mailAdmin.status.title': 'Estado',
|
||||
'mailAdmin.status.lastRead': 'Última lectura {time}',
|
||||
'mailAdmin.status.never': 'nunca',
|
||||
'mailAdmin.status.mails': 'Correos, 24 h',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Teavitused',
|
||||
'admin.notifications.channels': 'Kanalid',
|
||||
'admin.notifications.adminAlerts': 'Halduri teavitused',
|
||||
'admin.notifications.hint': 'Vali üks teavituskanal. Korraga saab aktiivne olla ainult üks kanal.',
|
||||
'admin.notifications.none': 'Keelatud',
|
||||
'admin.notifications.email': 'E-post (SMTP)',
|
||||
@@ -128,6 +130,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'Kutselingi loomine ebaõnnestus',
|
||||
'admin.invite.deleteError': 'Kutselingi kustutamine ebaõnnestus',
|
||||
'admin.tabs.settings': 'Seaded',
|
||||
'admin.tabs.security': 'Turvalisus',
|
||||
'admin.tabs.services': 'Teenused',
|
||||
'admin.allowRegistration': 'Luba registreerumine',
|
||||
'admin.allowRegistrationHint': 'Uued kasutajad saavad ise registreeruda',
|
||||
'admin.authMethods': 'Autentimisviisid',
|
||||
@@ -188,10 +192,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Olemas',
|
||||
'admin.trekApi.notIncluded': 'Puudub',
|
||||
'admin.trekApi.quota.title': 'Tänane kvoot',
|
||||
'admin.trekApi.quota.used': 'Kasutatud',
|
||||
'admin.trekApi.quota.remaining': 'Järel',
|
||||
'admin.trekApi.quota.resets': 'Lähtestub kell',
|
||||
'admin.trekApi.quota.limit': 'Limiit',
|
||||
'admin.trekApi.quota.key': 'Võti',
|
||||
'admin.trekApi.quota.state.connected': 'Ühendatud',
|
||||
'admin.trekApi.quota.state.registering': 'Seadistamisel',
|
||||
@@ -314,8 +316,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'Mis järgmiseks',
|
||||
'admin.collab.whatsnext.subtitle': 'Tegevussoovitused ja järgmised sammud',
|
||||
'admin.tabs.config': 'Isikupärastamine',
|
||||
'admin.tabs.defaults': 'Kasutaja vaikevalikud',
|
||||
'admin.tabs.defaults': 'Vaikeväärtused',
|
||||
'admin.defaultSettings.title': 'Kasutajate vaikeseaded',
|
||||
'admin.defaultSettings.routing': 'Marsruudid',
|
||||
'admin.defaultSettings.description':
|
||||
'Määra kogu paigalduse vaikeseaded. Neid väärtusi näevad kasutajad, kes pole vastavat seadet muutnud. Kasutaja enda muudatused on alati ülimuslikud.',
|
||||
'admin.defaultSettings.saved': 'Vaikeväärtus salvestatud',
|
||||
@@ -788,16 +791,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Lisamoodul uuendatud',
|
||||
'admin.addons.toast.error': 'Lisamooduli uuendamine ebaõnnestus',
|
||||
'admin.addons.group.count': 'Lubatud {enabled}/{total}',
|
||||
'admin.addons.search': 'Otsi lisasid',
|
||||
'admin.addons.filter': 'Näita',
|
||||
'admin.addons.filter.all': 'Kõik',
|
||||
'admin.addons.filter.on': 'Sees',
|
||||
'admin.addons.filter.off': 'Väljas',
|
||||
'admin.addons.noMatch': 'Otsingule ega filtrile ei vasta ükski lisa.',
|
||||
'admin.addons.subOptions': '{count} valikut',
|
||||
'admin.addons.subOptions.one': '{count} valik',
|
||||
'admin.addons.subOptionsOn': '{count} lubatud',
|
||||
'admin.addons.subOptionsOn.one': '{count} lubatud',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle':
|
||||
'Kasutajad saavad ühendada oma wanderer-serveri ja importida selle matkarajad retkedena',
|
||||
@@ -824,7 +817,7 @@ const admin: TranslationStrings = {
|
||||
'admin.weather.requestsDesc': 'Tasuta, API-võtit pole vaja',
|
||||
'admin.weather.locationHint':
|
||||
'Ilm põhineb iga päeva esimesel koordinaatidega kohal. Kui päevale pole ühtegi kohta määratud, kasutatakse võrdluskohana mõnda kohta kohtade nimekirjast.',
|
||||
'admin.tabs.mcpTokens': 'MCP juurdepääs',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'MCP juurdepääs',
|
||||
'admin.mcpTokens.subtitle': 'Halda kõigi kasutajate OAuth-seansse ja API-pääsutõendeid',
|
||||
'admin.mcpTokens.sectionTitle': 'API-pääsutõendid',
|
||||
@@ -926,7 +919,6 @@ const admin: TranslationStrings = {
|
||||
'admin.passkey.resetDone.one': 'Eemaldatud {count} pääsuvõti',
|
||||
'admin.group.users': 'Kasutajad',
|
||||
'admin.group.config': 'Seadistus',
|
||||
'admin.group.integration': 'Integratsioonid',
|
||||
'admin.group.maintenance': 'Hooldus',
|
||||
'admin.invite.tripLabel': 'Lisa reisile (valikuline)',
|
||||
'admin.invite.tripNone': 'Reis puudub',
|
||||
|
||||
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
|
||||
'mailAdmin.problem.portInvalid': 'Sisesta port vahemikus 1 kuni 65535.',
|
||||
'mailAdmin.problem.templateToken': 'Aadressis peab iga inimese koodi jaoks olema {token}.',
|
||||
'mailAdmin.problem.dailyInvalid': 'Sisesta täisarv vahemikus 0 kuni 1000.',
|
||||
'mailAdmin.status.title': 'Olek',
|
||||
'mailAdmin.status.lastRead': 'Viimati loetud {time}',
|
||||
'mailAdmin.status.never': 'mitte kunagi',
|
||||
'mailAdmin.status.mails': 'Kirjad, 24 t',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Notifications', // same-as-en
|
||||
'admin.notifications.channels': 'Canaux',
|
||||
'admin.notifications.adminAlerts': 'Alertes admin',
|
||||
'admin.notifications.hint': 'Choisissez un canal de notification. Un seul peut être actif à la fois.',
|
||||
'admin.notifications.none': 'Désactivé',
|
||||
'admin.notifications.email': 'E-mail (SMTP)',
|
||||
@@ -74,6 +76,8 @@ const admin: TranslationStrings = {
|
||||
'admin.invite.createError': 'Erreur lors de la création du lien',
|
||||
'admin.invite.deleteError': 'Erreur lors de la suppression du lien',
|
||||
'admin.tabs.settings': 'Paramètres',
|
||||
'admin.tabs.security': 'Sécurité',
|
||||
'admin.tabs.services': 'Services',
|
||||
'admin.allowRegistration': 'Autoriser les inscriptions',
|
||||
'admin.allowRegistrationHint': "Les nouveaux utilisateurs peuvent s'inscrire eux-mêmes",
|
||||
'admin.authMethods': "Méthodes d'authentification",
|
||||
@@ -137,10 +141,8 @@ const admin: TranslationStrings = {
|
||||
'admin.trekApi.included': 'Inclus',
|
||||
'admin.trekApi.notIncluded': 'Non inclus',
|
||||
'admin.trekApi.quota.title': 'Quota du jour',
|
||||
'admin.trekApi.quota.used': 'Utilisé',
|
||||
'admin.trekApi.quota.remaining': 'Restant',
|
||||
'admin.trekApi.quota.resets': 'Remise à zéro à',
|
||||
'admin.trekApi.quota.limit': 'Limite',
|
||||
'admin.trekApi.quota.key': 'Clé',
|
||||
'admin.trekApi.quota.state.connected': 'Connecté',
|
||||
'admin.trekApi.quota.state.registering': 'Configuration en cours',
|
||||
@@ -266,8 +268,9 @@ const admin: TranslationStrings = {
|
||||
'admin.collab.whatsnext.title': 'Et ensuite',
|
||||
'admin.collab.whatsnext.subtitle': "Suggestions d'activités et prochaines étapes",
|
||||
'admin.tabs.config': 'Personnalisation',
|
||||
'admin.tabs.defaults': 'Valeurs par défaut',
|
||||
'admin.tabs.defaults': 'Préréglages',
|
||||
'admin.defaultSettings.title': 'Paramètres utilisateur par défaut',
|
||||
'admin.defaultSettings.routing': 'Itinéraires',
|
||||
'admin.defaultSettings.description':
|
||||
"Définissez des valeurs par défaut pour toute l'instance. Les utilisateurs n'ayant pas modifié un paramètre verront ces valeurs. Leurs propres modifications ont toujours la priorité.",
|
||||
'admin.defaultSettings.saved': 'Valeur par défaut enregistrée',
|
||||
@@ -732,16 +735,6 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.toast.updated': 'Extension mise à jour',
|
||||
'admin.addons.toast.error': "Échec de la mise à jour de l'extension",
|
||||
'admin.addons.group.count': '{enabled} sur {total} activés',
|
||||
'admin.addons.search': 'Rechercher des modules',
|
||||
'admin.addons.filter': 'Afficher',
|
||||
'admin.addons.filter.all': 'Tous',
|
||||
'admin.addons.filter.on': 'Activés',
|
||||
'admin.addons.filter.off': 'Désactivés',
|
||||
'admin.addons.noMatch': 'Aucun module ne correspond à la recherche ou au filtre.',
|
||||
'admin.addons.subOptions': '{count} options', // same-as-en
|
||||
'admin.addons.subOptions.one': '{count} option', // same-as-en
|
||||
'admin.addons.subOptionsOn': '{count} activées',
|
||||
'admin.addons.subOptionsOn.one': '{count} activée',
|
||||
'admin.toursWanderer.title': 'wanderer',
|
||||
'admin.toursWanderer.subtitle':
|
||||
'Les utilisateurs peuvent connecter leur propre instance wanderer et importer ses sentiers comme sorties',
|
||||
@@ -781,7 +774,7 @@ const admin: TranslationStrings = {
|
||||
'admin.audit.col.resource': 'Ressource',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Détails',
|
||||
'admin.tabs.mcpTokens': 'Accès MCP',
|
||||
'admin.tabs.mcpTokens': 'MCP',
|
||||
'admin.mcpTokens.title': 'Accès MCP',
|
||||
'admin.mcpTokens.subtitle': 'Gérer les sessions OAuth et les tokens API de tous les utilisateurs',
|
||||
'admin.mcpTokens.sectionTitle': 'Tokens API',
|
||||
@@ -940,7 +933,6 @@ const admin: TranslationStrings = {
|
||||
"Utilisée pour chaque utilisateur n'ayant pas saisi sa propre clé, ainsi toute l'instance reçoit les tuiles CARTO sans filigrane. Stockée de façon chiffrée.",
|
||||
'admin.group.users': 'Utilisateurs',
|
||||
'admin.group.config': 'Configuration', // same-as-en
|
||||
'admin.group.integration': 'Intégrations',
|
||||
'admin.group.maintenance': 'Maintenance', // same-as-en
|
||||
'admin.invite.tripLabel': 'Ajouter à un voyage (facultatif)',
|
||||
'admin.invite.tripNone': 'Aucun voyage',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user