From 788a58679e2f9b69da48a7bfe07aa321ee47deb8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 1 Oct 2026 16:00:37 +0200 Subject: [PATCH] feat(settings,admin): every tab and popup in the planner's design --- client/src/components/Admin/AddonManager.tsx | 254 +-- client/src/components/Admin/AddonSubRow.tsx | 9 +- client/src/components/Admin/AddonTile.tsx | 54 +- .../Admin/AdminMcpTokensPanel.test.tsx | 20 +- .../components/Admin/AdminMcpTokensPanel.tsx | 327 ++- .../Admin/AdminPluginsPanel.test.tsx | 34 +- .../components/Admin/AdminPluginsPanel.tsx | 1828 +++++++++-------- client/src/components/Admin/AuditLogPanel.tsx | 136 +- .../src/components/Admin/BackupPanel.test.tsx | 33 +- client/src/components/Admin/BackupPanel.tsx | 660 +++--- .../src/components/Admin/CategoryManager.tsx | 229 ++- .../Admin/DefaultUserSettingsTab.test.tsx | 4 +- .../Admin/DefaultUserSettingsTab.tsx | 525 +++-- .../Admin/DevNotificationsPanel.test.tsx | 7 +- .../Admin/DevNotificationsPanel.tsx | 186 +- .../src/components/Admin/GitHubPanel.test.tsx | 2 +- client/src/components/Admin/GitHubPanel.tsx | 578 +++--- .../components/Admin/GoogleDailyLimitRow.tsx | 81 +- .../Admin/PackingTemplateManager.test.tsx | 86 +- .../Admin/PackingTemplateManager.tsx | 341 +-- .../src/components/Admin/PermissionsPanel.tsx | 140 +- .../Admin/PluginPoiCategoryList.tsx | 11 +- .../Admin/RoutingInstanceFields.tsx | 46 +- .../components/Admin/SchoolHolidayCatalog.tsx | 93 +- .../Admin/SchoolHolidayRegionEditor.tsx | 110 +- .../Admin/storage/AdminStoragePanel.tsx | 799 ++++--- .../components/Admin/storage/BackendForm.tsx | 206 +- client/src/components/Settings/AboutTab.tsx | 404 ++-- .../components/Settings/AccountTab.test.tsx | 16 +- client/src/components/Settings/AccountTab.tsx | 715 ++++--- .../AirTrailConnectionSection.test.tsx | 6 +- .../Settings/AirTrailConnectionSection.tsx | 127 +- .../Settings/ApiKeysSection.test.tsx | 4 +- .../components/Settings/ApiKeysSection.tsx | 227 +- .../Settings/AppearanceSettingsTab.tsx | 528 +++-- .../Settings/DawarichConnectionSection.tsx | 188 +- .../Settings/DisplaySettingsTab.test.tsx | 85 +- .../Settings/DisplaySettingsTab.tsx | 564 +++-- .../Settings/IntegrationsTab.test.tsx | 37 +- .../components/Settings/IntegrationsTab.tsx | 1088 +++++----- .../Settings/LlmConnectionSection.test.tsx | 4 +- .../Settings/LlmConnectionSection.tsx | 67 +- .../components/Settings/MapSettingsTab.tsx | 503 +++-- .../src/components/Settings/MapboxPreview.tsx | 5 +- .../components/Settings/MobileNavPreview.tsx | 19 +- .../components/Settings/NotificationsTab.tsx | 343 ++-- .../components/Settings/OfflineTab.test.tsx | 14 +- client/src/components/Settings/OfflineTab.tsx | 352 ++-- .../components/Settings/PasskeysSection.tsx | 276 +-- .../Settings/PhotoProvidersSection.tsx | 149 +- .../Settings/PluginActivityPanel.tsx | 82 +- .../Settings/PluginSettingsTab.test.tsx | 13 +- .../components/Settings/PluginSettingsTab.tsx | 232 ++- .../src/components/Settings/ToggleSwitch.tsx | 13 +- .../src/components/Settings/WebPushCard.tsx | 65 +- client/src/pages/AdminPage.test.tsx | 59 +- client/src/pages/AdminPage.tsx | 65 +- client/src/pages/SettingsPage.tsx | 2 +- .../admin/AdminNotificationDefaultsPanel.tsx | 94 +- .../admin/AdminNotificationsPanel.test.tsx | 6 +- .../pages/admin/AdminNotificationsPanel.tsx | 109 +- .../admin/AdminNotificationsTab.test.tsx | 16 +- .../src/pages/admin/AdminNotificationsTab.tsx | 561 ++--- .../src/pages/admin/AdminSettingsTab.test.tsx | 17 +- client/src/pages/admin/AdminSettingsTab.tsx | 923 ++++----- client/src/pages/admin/AdminStatCard.test.tsx | 4 +- client/src/pages/admin/AdminStatCard.tsx | 18 +- client/src/pages/admin/AdminUpdateBanner.tsx | 45 +- .../src/pages/admin/AdminUserModals.test.tsx | 32 +- client/src/pages/admin/AdminUserModals.tsx | 674 +++--- client/src/pages/admin/AdminUsersTab.test.tsx | 18 +- client/src/pages/admin/AdminUsersTab.tsx | 371 ++-- client/src/pages/admin/GoogleOptions.tsx | 21 +- client/src/pages/admin/ProviderBlock.tsx | 33 +- client/src/pages/admin/TrekApiCard.tsx | 164 +- 75 files changed, 8134 insertions(+), 8023 deletions(-) diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 9f556753e..d7973a792 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState, type ComponentType } from 'react' +import { useEffect, useId, useState, type ComponentType } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useAddonStore } from '../../store/addonStore' import { useIsDark } from '../../hooks/useIsDark' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' import EmptyState from '../shared/EmptyState' @@ -13,6 +13,10 @@ import AirTrailIcon from '../shared/AirTrailIcon' import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons' import AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' +import { Tooltip } from '../shared/Tooltip' +import { fs } from '../shared/DialogShell' +import { EditorField, INPUT, Segmented } from '../shared/dialogParts' +import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit' // Keys are the `icon` column from the addons table (see server seeds.ts); anything // unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which @@ -183,9 +187,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, if (loading) { return ( -
- -
+ +
+ +
+
) } @@ -278,9 +284,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, } /* One column per type, side by side, instead of three stacked sections. Stacked, each - section opened its own set of columns and the tall tiles (Collab 261px, Journey - 185px) set a section's height while its neighbours ran out early. Side by side the - column heading carries the type, so the tiles need no badge of their own. */ + section opened its own set of columns and the tall tiles (Collab, Journey) set a + section's height while its neighbours ran out early. Side by side the column's + head carries the type, so the tiles need no badge of their own. */ const groups = [ { key: 'trip', addons: tripAddons }, { key: 'global', addons: globalAddons }, @@ -289,48 +295,49 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length - return ( -
-
-
-

{t('admin.addons.title')}

-

- {t('admin.addons.subtitleBefore')} - TREK - {t('admin.addons.subtitleAfter')} -

-
- {totalCount > 0 && ( - - {enabledCount}/{totalCount} - - )} -
+ const subtitle = ( + + {t('admin.addons.subtitleBefore')} + TREK + {t('admin.addons.subtitleAfter')} + + ) + return ( + 0 ? ( + + + 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount} + + + ) : undefined} + > {addons.length === 0 ? ( - + ) : ( -
+
{groups.map(g => { const meta = TYPE_META[g.key] return ( -
+
-
{g.addons.map(tile)}
+
{g.addons.map(tile)}
) })}
)} -
+
) } +/** The head band of one type's column: icon tile, the type, its count and what it means. */ function GroupHead({ icon: Icon, label, hint, addons, t }: { - icon: ComponentType<{ size?: number; className?: string }> + icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }> label: string hint: string addons: Addon[] @@ -338,24 +345,29 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: { }) { const enabled = addons.filter(a => a.enabled).length return ( -
-
- -

{label}

- - {enabled}/{addons.length} - +
+ + + +
+
+

{label}

+ + + {enabled}/{addons.length} + + +
+ {/* Says what the type means: the only place that still explains it. */} +

{hint}

- {/* Says what the type means — the only place that still explains it. */} -

{hint}

) } const MASKED = '••••••••' +const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint' +const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60' const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1' /** Curated models the local extractor is tuned for, pullable via Ollama. The router drives @@ -381,6 +393,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { const [apiKey, setApiKey] = useState((cfg.apiKey as string) ?? '') const [vision, setVision] = useState(asLlmVision(cfg.vision)) const [saving, setSaving] = useState(false) + const fieldId = useId() // Local-provider model management. const [installed, setInstalled] = useState([]) @@ -450,9 +463,6 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { } } - const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none' - const labelCls = 'mb-1 block text-caption font-medium text-content-secondary' - const providerOptions = [ { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' }, { value: 'openai', label: 'OpenAI', icon: }, @@ -460,65 +470,69 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { ] const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) })) - /* Lives in the tile's shelf like the collab toggles, so everything is caption- - sized and single-column — the band this used to be had a whole page width. */ + /* Lives in the tile's shelf like the collab toggles, so it is one column of + eyebrow fields: the band this used to be had a whole page width. */ return ( -
  • -

    +

  • + Instance-wide — applies to all users. Leave blank to let each user configure their own provider. -

    +
    -
    - Provider + setProvider(String(v))} options={providerOptions} /> -
    + {provider !== 'anthropic' && ( - + + setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} /> + )} - - {provider === 'anthropic' && ( -

    Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.

    - )} - + + setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} /> + + + setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} /> + -
    - {t('settings.aiParsing.multimodal')} - setVision(asLlmVision(v))} options={visionOptions} /> -

    - {t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')} -

    -
    + + + label={t('settings.aiParsing.multimodal')} + value={vision} + onChange={v => setVision(asLlmVision(v))} + options={visionOptions} + fill + /> + {/* Local model management (Ollama) */} {provider === 'local' && ( -
    -
    - Installed on the server -
    - {modelsErr &&

    {modelsErr}

    } + {modelsErr &&

    {modelsErr}

    } {!modelsErr && installed.length === 0 && !loadingModels && ( -

    No models installed yet — pull one below.

    + No models installed yet — pull one below. )} {installed.length > 0 && ( -
    +
    {installed.map(name => ( @@ -526,48 +540,44 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
    )} -
    -
    Pull a recommended model
    -
    - {RECOMMENDED_MODELS.map(m => { - const installedHere = isInstalled(m.id) - const isPulling = pulling === m.id - const active = model === m.id - return ( -
    -
    - {m.label} - {m.recommended && ( - Recommended - )} - {installedHere ? ( - - ) : ( - - )} -
    - {isPulling && ( -
    -
    -
    -
    -
    {pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
    -
    +
    + Pull a recommended model + {RECOMMENDED_MODELS.map(m => { + const installedHere = isInstalled(m.id) + const isPulling = pulling === m.id + const active = model === m.id + return ( +
    +
    + {m.label} + {m.recommended && Recommended} + {installedHere ? ( + + ) : ( + )}
    - ) - })} -
    + {isPulling && ( +
    +
    +
    +
    +
    {pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
    +
    + )} +
    + ) + })}
    )}
    -
    diff --git a/client/src/components/Admin/AddonSubRow.tsx b/client/src/components/Admin/AddonSubRow.tsx index dc529c9e8..5a2829f7e 100644 --- a/client/src/components/Admin/AddonSubRow.tsx +++ b/client/src/components/Admin/AddonSubRow.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import ToggleSwitch from '../Settings/ToggleSwitch' +import { fs } from '../shared/DialogShell' /** * One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under @@ -29,9 +30,11 @@ export default function AddonSubRow({ onToggle: () => void }) { return ( -
  • - {icon ? {icon} : } - +
  • + {icon + ? {icon} + : } + {title} diff --git a/client/src/components/Admin/AddonTile.tsx b/client/src/components/Admin/AddonTile.tsx index 10760615a..da39734ec 100644 --- a/client/src/components/Admin/AddonTile.tsx +++ b/client/src/components/Admin/AddonTile.tsx @@ -1,21 +1,18 @@ import type { ReactNode } from 'react' import ToggleSwitch from '../Settings/ToggleSwitch' +import { fs } from '../shared/DialogShell' /** - * One addon in the admin grid: a card whose fill carries the on/off state, with - * an optional shelf at the foot holding its sub-features. + * 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. * - * The fill ladder is `--bg-secondary` (on) against `--bg-card` (the panel it - * sits on) — that pair reads in both themes, because secondary is *darker* than - * the card in light and *lighter* in dark. The obvious alternative, on = - * `bg-surface-card`, inverts in dark and turns the disabled tiles into the loud - * ones. + * 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". * - * Hover and focus ride on border and fill rather than a shadow: `--shadow-card` - * is only declared in `:root`, so the `.dark` palette never overrides it and a - * shadow-based affordance would be invisible there. - * - * The card is not a click target — only the switches are. No lift, no pointer + * The row is not a click target, only the switches are. No lift, no pointer * cursor, no `role="button"`. */ export default function AddonTile({ @@ -35,46 +32,41 @@ export default function AddonTile({ children?: ReactNode }) { return ( -
    -
    +
    +
    {icon}
    -
    - {/* The name stays full-strength when off: it is the tile's identity, not its - state. Greying it out reads as "broken" rather than "not enabled". */} -

    +
    +

    {name}

    {description}

    - +
    + +

    {children && ( -
    +
    {/* Sitting inside the parent on its own fill is enough to say these belong - to it — no rail, no indent guides. */} -
      {children}
    + to it: no rail, no indent guides. */} +
      {children}
    )}
    diff --git a/client/src/components/Admin/AdminMcpTokensPanel.test.tsx b/client/src/components/Admin/AdminMcpTokensPanel.test.tsx index edd016b1c..f6fe60bba 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.test.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.test.tsx @@ -89,7 +89,7 @@ describe('AdminMcpTokensPanel', () => { render(); await screen.findByText('CI Token'); - const deleteButtons = screen.getAllByTitle('Delete'); + const deleteButtons = screen.getAllByLabelText('Delete'); await user.click(deleteButtons[0]); expect(screen.getByText('Delete Token')).toBeInTheDocument(); @@ -108,7 +108,7 @@ describe('AdminMcpTokensPanel', () => { render(); await screen.findByText('CI Token'); - const deleteButtons = screen.getAllByTitle('Delete'); + const deleteButtons = screen.getAllByLabelText('Delete'); await user.click(deleteButtons[0]); expect(screen.getByText('Delete Token')).toBeInTheDocument(); @@ -129,7 +129,7 @@ describe('AdminMcpTokensPanel', () => { render(); await screen.findByText('CI Token'); - const deleteButtons = screen.getAllByTitle('Delete'); + const deleteButtons = screen.getAllByLabelText('Delete'); await user.click(deleteButtons[0]); expect(screen.getByText('Delete Token')).toBeInTheDocument(); @@ -155,7 +155,7 @@ describe('AdminMcpTokensPanel', () => { render(<>); await screen.findByText('CI Token'); - const deleteButtons = screen.getAllByTitle('Delete'); + const deleteButtons = screen.getAllByLabelText('Delete'); await user.click(deleteButtons[0]); await user.click(screen.getByText('Delete')); @@ -180,7 +180,7 @@ describe('AdminMcpTokensPanel', () => { render(<>); await screen.findByText('CI Token'); - const deleteButtons = screen.getAllByTitle('Delete'); + const deleteButtons = screen.getAllByLabelText('Delete'); await user.click(deleteButtons[0]); await user.click(screen.getByText('Delete')); @@ -282,14 +282,14 @@ describe('AdminMcpTokensPanel', () => { await screen.findByText('Revoke Me'); // Click the revoke (trash) button next to the session - const deleteBtn = screen.getAllByTitle('Delete')[0]; + const deleteBtn = screen.getAllByLabelText('Delete')[0]; await user.click(deleteBtn); // Confirmation modal opens expect(screen.getByText('Revoke Session')).toBeInTheDocument(); - // Confirm — find the modal's Delete button (has no title, unlike the trash icon) + // Confirm — find the modal's Delete button (has no aria-label, unlike the trash icon) const deleteBtns = screen.getAllByRole('button', { name: 'Delete' }); - const confirmBtn = deleteBtns.find(b => !b.title); + const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label')); await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]); await waitFor(() => { expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument(); @@ -313,10 +313,10 @@ describe('AdminMcpTokensPanel', () => { render(<>); await screen.findByText('Error Session'); - const deleteBtn = screen.getAllByTitle('Delete')[0]; + const deleteBtn = screen.getAllByLabelText('Delete')[0]; await user.click(deleteBtn); const deleteBtns = screen.getAllByRole('button', { name: 'Delete' }); - const confirmBtn = deleteBtns.find(b => !b.title); + const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label')); await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]); expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument(); }); diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx index 221f4da9c..a23a96234 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -1,8 +1,12 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, type ReactNode } from 'react' import { adminApi } from '../../api/client' import { useToast } from '../shared/Toast' -import { Key, Trash2, User, Loader2, Shield } from 'lucide-react' +import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react' import { useTranslation } from '../../i18n' +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' interface AdminOAuthSession { id: number @@ -28,6 +32,58 @@ interface AdminMcpToken { const SCOPES_PREVIEW = 6 +const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3' +const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary' +const CHIP = 'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted' + +/** The spinner or the empty line a list shows in place of its rows. */ +function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode; text: string }) { + if (loading) { + return ( +
    + +
    + ) + } + return ( +
    + {icon} + {text} +
    + ) +} + +/** One fact on the right of a row: an eyebrow over its value. */ +function Meta({ label, children }: { label: string; children: ReactNode }) { + return ( +
    +
    {label}
    +
    {children}
    +
    + ) +} + +/** The owner of a session or token, with the person glyph in front. */ +function Owner({ name }: { name: string }) { + return ( + + + {name} + + ) +} + +/** The trash button of a row, named by its tooltip. */ +function DeleteAction({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + + + ) +} + export default function AdminMcpTokensPanel() { const [sessions, setSessions] = useState([]) const [sessionsLoading, setSessionsLoading] = useState(true) @@ -80,188 +136,107 @@ export default function AdminMcpTokensPanel() { } } - return ( -
    -
    -

    {t('admin.mcpTokens.title')}

    -

    {t('admin.mcpTokens.subtitle')}

    -
    + const date = (iso: string) => new Date(iso).toLocaleDateString(locale) - {/* OAuth Sessions */} -
    -

    {t('admin.oauthSessions.sectionTitle')}

    -
    - {sessionsLoading ? ( -
    - -
    - ) : sessions.length === 0 ? ( -
    - -

    {t('admin.oauthSessions.empty')}

    -
    - ) : ( - <> -
    - {t('admin.oauthSessions.clientName')} - {t('admin.oauthSessions.owner')} - {t('admin.oauthSessions.created')} - -
    - {sessions.map((session, i) => { - const expanded = expandedScopes.has(session.id) - const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW) - const hidden = session.scopes.length - SCOPES_PREVIEW - return ( -
    -
    -

    {session.client_name}

    -
    - {visible.map(scope => ( - - {scope} - - ))} - {!expanded && hidden > 0 && ( - - )} - {expanded && hidden > 0 && ( - - )} -
    + return ( +
    + {/* 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. */} + 0 ? {sessions.length} : undefined} + > + {sessionsLoading || sessions.length === 0 ? ( + } text={t('admin.oauthSessions.empty')} /> + ) : ( + + {sessions.map(session => { + const expanded = expandedScopes.has(session.id) + const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW) + const hidden = session.scopes.length - SCOPES_PREVIEW + return ( +
    + +
    +

    {session.client_name}

    +
    + {visible.map(scope => ( + {scope} + ))} + {hidden > 0 && ( + + )}
    -
    - - {session.username} -
    - - {new Date(session.created_at).toLocaleDateString(locale)} - -
    - ) - })} - - )} -
    -
    +
    + + {date(session.created_at)} + setRevokeConfirmId(session.id)} /> +
    +
    + ) + })} + + )} + {/* MCP Tokens */} -
    -

    {t('admin.mcpTokens.sectionTitle')}

    -
    - {tokensLoading ? ( -
    - -
    - ) : tokens.length === 0 ? ( -
    - -

    {t('admin.mcpTokens.empty')}

    -
    - ) : ( - <> -
    - {t('admin.mcpTokens.tokenName')} - {t('admin.mcpTokens.owner')} - {t('admin.mcpTokens.created')} - {t('admin.mcpTokens.lastUsed')} - -
    - {tokens.map((token, i) => ( -
    -
    -

    {token.name}

    -

    {token.token_prefix}...

    -
    -
    - - {token.username} -
    - - {new Date(token.created_at).toLocaleDateString(locale)} - - - {token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')} - - + 0 ? {tokens.length} : undefined} + > + {tokensLoading || tokens.length === 0 ? ( + } text={t('admin.mcpTokens.empty')} /> + ) : ( + + {tokens.map(token => ( +
    + +
    +

    {token.name}

    +

    {token.token_prefix}...

    - ))} - - )} -
    -
    +
    + + {date(token.created_at)} + + {token.last_used_at ? date(token.last_used_at) : t('admin.mcpTokens.never')} + + setDeleteConfirmId(token.id)} /> +
    +
    + ))} + + )} + - {/* Revoke OAuth session modal */} - {revokeConfirmId !== null && ( -
    { if (e.target === e.currentTarget) setRevokeConfirmId(null) }} - onKeyDown={e => { - if (e.target !== e.currentTarget) return - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) } - }}> -
    -

    {t('admin.oauthSessions.revokeTitle')}

    -

    {t('admin.oauthSessions.revokeMessage')}

    -
    - - -
    -
    -
    - )} + {/* Revoke OAuth session */} + setRevokeConfirmId(null)} + onConfirm={() => { if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId) }} + title={t('admin.oauthSessions.revokeTitle')} + message={t('admin.oauthSessions.revokeMessage')} + confirmLabel={t('common.delete')} + danger + /> - {/* Delete MCP token modal */} - {deleteConfirmId !== null && ( -
    { if (e.target === e.currentTarget) setDeleteConfirmId(null) }} - onKeyDown={e => { - if (e.target !== e.currentTarget) return - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) } - }}> -
    -

    {t('admin.mcpTokens.deleteTitle')}

    -

    {t('admin.mcpTokens.deleteMessage')}

    -
    - - -
    -
    -
    - )} + {/* Delete MCP token */} + setDeleteConfirmId(null)} + onConfirm={() => { if (deleteConfirmId !== null) void handleDelete(deleteConfirmId) }} + title={t('admin.mcpTokens.deleteTitle')} + message={t('admin.mcpTokens.deleteMessage')} + confirmLabel={t('common.delete')} + danger + />
    ) } diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 57f843e32..472f6a7da 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -37,6 +37,12 @@ function mockList(p: Record) { beforeEach(() => resetAllStores()) +/** A press on a dialog's backdrop: DialogShell closes only when the press both starts and ends there. */ +function clickBackdrop(el: HTMLElement) { + fireEvent.mouseDown(el) + fireEvent.click(el) +} + describe('AdminPluginsPanel — allowed-hosts chip', () => { it('FE-COMP-PLUGINS-EGRESS-001: invites the admin to add a host when none is set', async () => { mockList(plugin({ egressHostCount: 0 })) @@ -658,7 +664,7 @@ describe('AdminPluginsPanel — load states and toolbar', () => { withToast() await screen.findByText('Gotify') - fireEvent.click(screen.getAllByTitle('Rescan')[0]) + fireEvent.click(screen.getAllByRole('button', { name: 'Rescan' })[0]) expect(await screen.findByText('Rescanned the plugins folder')).toBeInTheDocument() expect(refreshFlags).toContain('1') @@ -676,7 +682,7 @@ describe('AdminPluginsPanel — sideloading', () => { const input = container.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {}) - fireEvent.click(screen.getAllByTitle('Upload plugin')[0]) + fireEvent.click(screen.getAllByRole('button', { name: 'Upload plugin' })[0]) expect(clickSpy).toHaveBeenCalled() clickSpy.mockRestore() @@ -763,7 +769,7 @@ describe('AdminPluginsPanel — row actions', () => { /** Every modal in the panel closes by clicking its backdrop. */ function closeModal() { - fireEvent.click(document.querySelector('.fixed.inset-0.z-50') as HTMLElement) + clickBackdrop(document.querySelector('.trek-modal-backdrop') as HTMLElement) } it('FE-COMP-PLUGINS-PANEL-014: the error log lists what the runtime recorded', async () => { @@ -888,7 +894,7 @@ describe('AdminPluginsPanel — row actions', () => { const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto') expect(body).not.toBeNull() expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com')) - expect(body!.parentElement!.className).toContain('max-h-[88vh]') + expect(body!.parentElement!.className).toContain('max-h-full') }) }) @@ -1875,7 +1881,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { await user.click(screen.getByRole('button', { name: /view error log/i })) const empty = await screen.findByText(/no errors/i) - fireEvent.click(empty.closest('.fixed') as HTMLElement) + clickBackdrop(empty.closest('.fixed') as HTMLElement) await waitFor(() => expect(screen.queryByText(/no errors/i)).not.toBeInTheDocument()) }) @@ -2052,7 +2058,7 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => { fireEvent.keyDown(card, { key: 'Enter' }) expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement) + clickBackdrop(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement) await waitFor(() => expect(screen.queryByRole('heading', { name: 'Mapper' })).not.toBeInTheDocument()) fireEvent.keyDown(card, { key: ' ' }) @@ -2174,7 +2180,7 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => { fireEvent.click(await screen.findByRole('button', { name: /review/i })) const detail = await screen.findByText('only half signed') - fireEvent.click(detail.closest('.fixed') as HTMLElement) + clickBackdrop(detail.closest('.fixed') as HTMLElement) await waitFor(() => expect(screen.queryByText('only half signed')).not.toBeInTheDocument()) }) @@ -2196,7 +2202,7 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => { await user.click(screen.getByRole('button', { name: /1 allowed host/i })) const reopened = (await screen.findByText('gotify.mydomain.com')).closest('.fixed') as HTMLElement - fireEvent.click(reopened) + clickBackdrop(reopened) await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument()) }) @@ -2355,15 +2361,17 @@ describe('AdminPluginsPanel — instance settings', () => { fireEvent.click(screen.getByText('Instance settings')) // Stored values land in the right controls; an unset value renders empty. - const select = await screen.findByRole('combobox', {}, { timeout: 5000 }) - expect(select).toHaveValue('slow') - expect(screen.getByRole('checkbox')).toBeChecked() + const select = await screen.findByRole('button', { name: 'Mode' }, { timeout: 5000 }) + expect(select).toHaveTextContent('Slow') + const toggle = screen.getByRole('button', { name: 'enabled' }) + expect(toggle).toHaveAttribute('aria-pressed', 'true') expect(screen.getByRole('spinbutton')).toHaveValue(null) expect(screen.getByText('Pick one')).toBeInTheDocument() // hint expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key - fireEvent.change(select, { target: { value: 'fast' } }) - fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(select) + fireEvent.click(await screen.findByRole('button', { name: 'Fast' })) + fireEvent.click(toggle) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } }) fireEvent.click(screen.getByRole('button', { name: /^save$/i })) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 21dc05b7a..38cdf3c12 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,8 +1,8 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react' +import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent, type ReactNode } from 'react' import { PLUGIN_PERMISSIONS } from '@trek/shared' import { createPortal } from 'react-dom' import { - Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, + Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database, Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2, @@ -18,6 +18,16 @@ import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import ConfirmDialog from '../shared/ConfirmDialog' import ToggleSwitch from '../Settings/ToggleSwitch' +import CustomSelect from '../shared/CustomSelect' +import EmptyState from '../shared/EmptyState' +import { Tooltip } from '../shared/Tooltip' +import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' +import { EditorField, INPUT } from '../shared/dialogParts' +import { + SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill, + SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, +} from '../Settings/settingsKit' +import { POPOVER } from '../Packing/packingPopoverStyles' /** * Admin → Plugins (#plugins). A full plugin-management surface: a segmented @@ -283,6 +293,31 @@ function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { bloc return { blocked: true, label: t('admin.plugins.incompatible'), title } } + +/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */ +const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)' +/** The eyebrow over a block, the size the dialogs' sections use. */ +const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint' +/** A capability or dependency chip on an installed row. */ +const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium' +const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary' +const CHIP_NET = 'border-transparent bg-info-soft text-info' +const CHIP_WARN = 'border-transparent bg-warning-soft text-warning' +/** A small action inside a row or a card: the kit's button, a size down. */ +const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50' +const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint' +/** A line of state under a row's name: a refused update, a paused one, an error. */ +const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5' + +/** A pill that explains itself on hover. */ +function HintPill({ hint, children }: { hint: string; children: ReactNode }) { + return ( + + {children} + + ) +} + function ReviewedBadge({ t }: { t: T }) { return } @@ -290,10 +325,9 @@ function ReviewedBadge({ t }: { t: T }) { /** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */ function SideloadedBadge({ t }: { t: T }) { return ( - - {t('admin.plugins.sideloaded')} - + + }>{t('admin.plugins.sideloaded')} + ) } @@ -323,33 +357,30 @@ function isRegistrySourced(sourceRepo: string | null | undefined): boolean { function TrustBadge({ signed, t }: { signed: boolean; t: T }) { if (signed) { return ( - - {t('admin.plugins.signed')} - + + }>{t('admin.plugins.signed')} + ) } return ( - - {t('admin.plugins.unsigned')} - + + }>{t('admin.plugins.unsigned')} + ) } /** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */ function DevLinkBadge({ t }: { t: T }) { return ( - - {t('admin.plugins.devLinkBadge')} - + + }>{t('admin.plugins.devLinkBadge')} + ) } function TypeBadge({ type, t }: { type: string; t: T }) { return ( - + {KNOWN_TYPES.includes(type) ? t(`admin.plugins.type.${type}` as never) : type} ) @@ -778,369 +809,208 @@ export default function AdminPluginsPanel() { }, [registry, q, typeFilter, sort]) const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all' + const ready = runtimeOn && !loading && !error + // A question opened from the detail view (the range warning, a signature refusal, the + // consent gate) sits on top of it; Escape and the backdrop leave the detail alone meanwhile. + const detailBlocked = !!bypass.copy || !!signatureBlock || consentQueue.length > 0 return ( -
    { if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}> - {/* Hidden input for the toolbar "Upload plugin" button (drag-drop uses the same handler). */} + {/* Hidden input for the "Upload plugin" button (drag-drop uses the same handler). */} { const f = e.target.files?.[0]; if (f) void uploadPlugin(f); e.target.value = '' }} /> {/* Drag-to-install overlay */} {dragActive && ( -
    - - {t('admin.plugins.dropToUpload')} +
    + + + + {t('admin.plugins.dropToUpload')}
    )} {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} {menu &&
    setMenu(null)} />} - {/* Header */} -
    -
    -
    -

    {t('admin.plugins.title')}

    -

    {t('admin.plugins.subtitle')}

    -
    -
    - {runtimeOn && ignoreTrekRange && ( - - {t('admin.plugins.rangeBypass.pill')} - + + + {ignoreTrekRange && ( + + }>{t('admin.plugins.rangeBypass.pill')} + )} - {runtimeOn && ( - - {t('admin.plugins.runtimeOn')} - - )} -
    -
    -
    - - {/* Runtime-disabled notice */} - {!runtimeOn && !loading && !error && ( -
    - -
    -

    {t('admin.plugins.disabledTitle')}

    -

    {t('admin.plugins.disabledBody')}

    -
    -
    - )} - - {/* Dev-link: register + hot-reload a plugin from a local build dir (dev only). */} - {devLink && runtimeOn && !loading && !error && ( -
    { e.preventDefault(); void linkLocal() }} - className="mx-4 sm:mx-6 mt-4 p-3 rounded-xl border border-accent/30 bg-accent-soft flex flex-col sm:flex-row sm:items-center gap-2.5"> -
    - - {t('admin.plugins.devLinkTitle')} -
    - setLinkPath(e.target.value)} spellCheck={false} - placeholder={t('admin.plugins.devLinkPathPlaceholder')} - className="flex-1 min-w-0 h-[34px] px-2.5 rounded-lg border border-edge bg-surface-card text-sm text-content placeholder:text-content-faint focus:outline-none focus:border-accent" /> - -
    - )} - - {/* Toolbar — on mobile: tabs+rescan, then full-width search, then a - right-aligned filter row. On sm+ the wrappers collapse (sm:contents) - so everything sits in one wrapping row. */} - {runtimeOn && !loading && !error && ( -
    -
    -
    - setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} /> - -
    -
    - - -
    -
    - -
    - - setQ(e.target.value)} type="search" - placeholder={t('admin.plugins.searchPlaceholder')} - className="w-full h-[38px] pl-9 pr-3 rounded-xl border border-edge bg-surface-secondary text-sm text-content placeholder:text-content-faint outline-none focus:bg-surface-card focus:border-content-faint transition-colors" - /> -
    - -
    - } - options={[ - ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], - ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], - ['trip-page', t('admin.plugins.type.trip-page')], - ]} - valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} - onPick={v => setTypeFilter(v as TypeFilter)} /> - - {view === 'installed' && ( - } - options={[ - ['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')], - ['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')], - ]} - valueLabel={statusLabel(statusFilter, t)} - onPick={v => setStatusFilter(v as StatusFilter)} /> - )} - - } - options={view === 'discover' - ? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]] - : [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} - valueLabel={sortLabel(sort, t)} - onPick={v => setSort(v as SortKey)} /> - - - -
    -
    - )} - - {/* Body */} -
    + }>{t('admin.plugins.runtimeOn')} + + ) : undefined} + action={ready ? ( + <> + + + + + + + + ) : undefined} + > {loading ? ( -
    {t('common.loading')}
    - ) : error ? ( -
    {t('admin.plugins.loadError')}
    - ) : !runtimeOn ? null : view === 'discover' ? ( - - ) : plugins.length === 0 ? ( - - ) : ( -
    - {updatable.length > 0 && statusFilter !== 'err' && ( -
    - - {t('admin.plugins.updatesAvailable', { count: updatable.length })} - -
    - )} - {shownInstalled.length === 0 ? ( -
    - -

    {t('admin.plugins.noMatchInstalled')}

    -
    - ) : shownInstalled.map(p => ( - toggle(p)} - onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} - onChangeVersion={() => openVersionPicker(p)} - onResume={() => void resumeUpdates(p)} - onReviewBlock={() => setSignatureBlock({ - subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, - code: p.updateBlock!.code, detail: p.updateBlock!.detail, - })} - onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} - onSettings={() => openInstanceSettings(p)} - onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> - ))} +
    + {t('common.loading')}
    + ) : error ? ( +
    {t('admin.plugins.loadError')}
    + ) : !runtimeOn ? ( + // Runtime switched off server-side: the notice replaces the whole body. +
    + +
    +

    {t('admin.plugins.disabledTitle')}

    +

    {t('admin.plugins.disabledBody')}

    +
    +
    + ) : ( + <> + {/* Dev-link: register + hot-reload a plugin from a local build dir (dev only). */} + {devLink && ( +
    { e.preventDefault(); void linkLocal() }} + className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-card p-3.5"> +
    + {t('admin.plugins.devLinkTitle')}} + htmlFor="plugin-dev-link" className="min-w-[220px] flex-1"> + setLinkPath(e.target.value)} spellCheck={false} + placeholder={t('admin.plugins.devLinkPathPlaceholder')} className={`${INPUT} font-mono`} /> + + +
    + {t('admin.plugins.devLinkHint')} +
    + )} + + {/* Toolbar: what to look at on the left, how to narrow and order it on the right. */} +
    +
    + setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} /> + +
    + + + + } + options={[ + ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], + ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], + ['trip-page', t('admin.plugins.type.trip-page')], + ]} + valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} + onPick={v => setTypeFilter(v as TypeFilter)} /> + + {view === 'installed' && ( + } + options={[ + ['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')], + ['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')], + ]} + valueLabel={statusLabel(statusFilter, t)} + onPick={v => setStatusFilter(v as StatusFilter)} /> + )} + + } + options={view === 'discover' + ? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]] + : [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} + valueLabel={sortLabel(sort, t)} + onPick={v => setSort(v as SortKey)} /> +
    + + {view === 'discover' ? ( + + ) : plugins.length === 0 ? ( + + {t('admin.plugins.tabDiscover')} + + )} + /> + ) : ( + <> + {updatable.length > 0 && statusFilter !== 'err' && ( +
    + + {t('admin.plugins.updatesAvailable', { count: updatable.length })} + +
    + )} + {shownInstalled.length === 0 ? ( + {t('admin.plugins.noMatchInstalled')} + ) : ( + + {shownInstalled.map(p => ( + toggle(p)} + onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} + onChangeVersion={() => openVersionPicker(p)} + onResume={() => void resumeUpdates(p)} + onReviewBlock={() => setSignatureBlock({ + subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, + code: p.updateBlock!.code, detail: p.updateBlock!.detail, + })} + onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} + onSettings={() => openInstanceSettings(p)} + onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> + ))} + + )} + + )} + )} -
    + {/* Registry detail dialog */} {detailFor && ( - setDetailFor(null)} /> )} {bypass.copy && } - {/* Error-log modal */} - {errorsFor && ( -
    setErrorsFor(null)}> -
    e.stopPropagation()}> -
    - {errorsFor.id} — {t('admin.plugins.errorLog')} - -
    -
    - {errorsFor.rows.length === 0 ?

    {t('admin.plugins.noErrors')}

    : - errorsFor.rows.map((r, i) => ( -
    - {r.level} - {r.ts} - {r.message} -
    - ))} -
    -
    -
    - )} + {errorsFor && setErrorsFor(null)} />} {/* Operator-supplied egress hosts */} {egressFor && ( -
    setEgressFor(null)}> -
    e.stopPropagation()}> -
    - {egressFor.id} — {t('admin.plugins.allowedHosts')} - -
    -
    - {!egressFor.supported ? ( -

    {t('admin.plugins.allowedHosts.unsupported')}

    - ) : ( - <> -

    {t('admin.plugins.allowedHosts.hint')}

    - {egressFor.hosts.length === 0 && ( -

    {t('admin.plugins.allowedHosts.none')}

    - )} - {egressFor.hosts.map(h => ( -
    - {h} - -
    - ))} -
    - setEgressDraft(e.target.value)} - placeholder="gotify.example.com" - className="flex-1 rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content" - /> - -
    - {egressError &&

    {egressError}

    } -

    {t('admin.plugins.allowedHosts.restartNote')}

    - - )} -
    -
    -
    + void saveEgress(hosts)} onClose={() => setEgressFor(null)} t={t} /> )} {/* Instance-wide settings (the admin-owned scope:'instance' fields) */} - {settings.form && ( -
    -
    e.stopPropagation()}> -
    - {settings.form.id} — {t('admin.plugins.instanceSettings')} - -
    -
    - {settings.form.fields.map(f => ( - - ))} - {(() => { - const form = settings.form - if (!form || form.actions.length === 0) return null - return ( -
    - - {t('admin.plugins.actions')} - - {!form.active && ( -

    {t('admin.plugins.actions.inactive')}

    - )} -
    - {form.actions.map(a => { - const res = settings.actionResult[a.key] - return ( -
    - - {a.hint && {a.hint}} - {res && ( - - {res.message || (res.ok ? t('common.success') : t('common.error'))} - - )} -
    - ) - })} -
    -
    - ) - })()} - {settings.error &&

    {settings.error}

    } -
    -
    - -
    -
    -
    - )} + {settings.form && } void; disabled?: boolean; children: ReactNode }) { + return ( + + + + ) +} + +/** "Nothing matches": a faint search glyph over one quiet line. */ +function NoMatch({ children }: { children: ReactNode }) { + return ( +
    + + + + {children} +
    + ) +} + function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) { return ( ) } +/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */ +function useFollowTrigger(open: boolean) { + const [, reanchor] = useState(0) + useEffect(() => { + if (!open) return + const onMove = () => reanchor(n => n + 1) + window.addEventListener('scroll', onMove, true) + window.addEventListener('resize', onMove) + return () => { + window.removeEventListener('scroll', onMove, true) + window.removeEventListener('resize', onMove) + } + }, [open]) +} + function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: { id: string; label: string; valueLabel: string options: Array<[string, string]>; onPick: (v: string) => void; value: string - menu: string | null; setMenu: (v: string | null) => void; icon?: React.ReactNode + menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode }) { const open = menu === id const active = value !== options[0]?.[0] + const btnRef = useRef(null) + // Portaled like the row menu: the card around the toolbar clips what overflows it. + useFollowTrigger(open) return ( -
    - - {open && ( -
    + {open && createPortal( +
    {options.map(([v, lbl]) => ( ))} -
    +
    , + document.body, )}
    ) } -function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { - return ( -
    -
    - -
    -

    {t('admin.plugins.empty')}

    - -
    - ) -} - function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean @@ -1298,37 +1197,32 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn const deps = deriveDeps(p, t) const menuOpen = menu === `row:${p.id}` const menuBtnRef = useRef(null) + const working = busy === p.id + const on = p.enabled === 1 && p.status !== 'error' // The menu is portaled to and positioned `fixed` against the ⋯ button, // because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip // an in-flow `absolute` menu — losing Delete on the lower rows of a long list. // Re-anchor while the page moves under it. - const [, reanchor] = useState(0) - useEffect(() => { - if (!menuOpen) return - const onMove = () => reanchor(n => n + 1) - window.addEventListener('scroll', onMove, true) - window.addEventListener('resize', onMove) - return () => { - window.removeEventListener('scroll', onMove, true) - window.removeEventListener('resize', onMove) - } - }, [menuOpen]) + useFollowTrigger(menuOpen) + + const hostCount = p.egressHostCount ?? 0 return ( -
    -
    -
    - -
    - +
    +
    + + + + + +
    -
    - {p.name} - {p.version && v{p.version}} +
    + {p.name} + {p.version && v{p.version}} {p.reviewed_at && } {p.source_repo === 'local:upload' && } {p.source_repo === 'local:link' && } @@ -1336,16 +1230,16 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */} {isRegistrySourced(p.source_repo) && }
    - {p.description &&

    {p.description}

    } + {p.description &&

    {p.description}

    } {/* A refused update leaves a WORKING plugin pinned at its old version — so this is its own row state, not an error state, and it persists until it is resolved rather than dying with the toast that first reported it. */} {blocked && p.updateBlock && ( -
    - +
    + {t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}
    @@ -1353,33 +1247,32 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn {/* A newer version this TREK can't run — informational, never a button: the fix is a TREK upgrade, so the row must not nag or offer a doomed install. */} {newerIncompatible && ( -
    - +
    + {t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}
    )} {/* Held: the admin deliberately installed a non-latest version, so updates are paused rather than nagged — resuming is one click, right where the pause shows. */} {p.updateHold && ( -
    - +
    + {t('admin.plugins.updatesHeld', { version: p.version ?? '' })} -
    )} {p.status === 'error' && p.last_error ? ( -
    - {p.last_error} +
    + {p.last_error}
    ) : (caps.length > 0 || p.operatorEgress) && ( -
    +
    {caps.map((c, i) => ( - - {c.label} + + {c.label} ))} {/* This plugin talks to a service only the OPERATOR can name (a self-hosted @@ -1387,53 +1280,52 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn Actionable, and warning-toned until at least one host exists, because until then the plugin cannot reach anything and looks silently broken. */} {p.operatorEgress && ( - + + + )}
    )} {deps.length > 0 && ( -
    +
    {deps.map((d, i) => ( - - {d.label} + + {d.label} ))}
    )}
    -
    +
    {hasUpdate && ( - )} - + {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
    - + + + {menuOpen && createPortal( -
    +
    {p.enabled === 1 && ( } label={t('admin.plugins.restart')} onClick={onRestart} /> )} @@ -1456,16 +1348,16 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( <> setMenu(null)} - className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> - {t('admin.plugins.sourceRepo')} + className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}> + {t('admin.plugins.sourceRepo')} setMenu(null)} - className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> - {t('admin.plugins.reportIssue')} + className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}> + {t('admin.plugins.reportIssue')} )} -
    +
    } label={t('common.delete')} danger onClick={onUninstall} />
    , document.body, @@ -1493,12 +1385,14 @@ function anchorMenu(trigger: HTMLElement | null): CSSProperties { } } -function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) { +/** One entry of a popover menu, in the packing popovers' shape. */ +const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary' + +function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) { return ( ) } @@ -1506,12 +1400,12 @@ function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; lab function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) { const [failed, setFailed] = useState(false) return ( -
    +
    {url && !failed ? ( - setFailed(true)} /> + setFailed(true)} /> ) : ( -
    - +
    +
    )}
    @@ -1528,15 +1422,14 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f filtered: boolean ignoreTrekRange: boolean }) { - if (!items) return
    {t('common.loading')}
    - if (items.length === 0) return ( -
    - -

    {filtered ? t('admin.plugins.noMatchRegistry') : t('admin.plugins.registryEmpty')}

    + if (!items) return ( +
    + {t('common.loading')}
    ) + if (items.length === 0) return {filtered ? t('admin.plugins.noMatchRegistry') : t('admin.plugins.registryEmpty')} return ( -
    +
    {items.map(item => { const installed = installedIds.has(item.id) const offer = installOffer(item, t, ignoreTrekRange) @@ -1547,37 +1440,40 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f // and opens the detail modal instead of installing (#2158). data-no-press onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }} - className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150"> + className="group flex min-w-0 cursor-pointer flex-col overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card shadow-sm outline-none transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]">
    -
    {item.reviewedAt && ( - - {t('admin.plugins.reviewed')} + + }>{t('admin.plugins.reviewed')} )} -
    - -
    + + +
    -
    - {item.name} - {item.author} -

    {item.description}

    -
    +
    + {item.name} + {item.author} +

    {item.description}

    +
    {/* Everything in Discover is registry-sourced, so the badge always applies. */} - {item.latest && v{item.latest}} + {item.latest && v{item.latest}} {typeof item.downloadCount === 'number' && item.downloadCount > 0 && ( - - {formatCompactCount(item.downloadCount)} - + + + {formatCompactCount(item.downloadCount)} + + )} +
    +
    @@ -1601,7 +1497,37 @@ function formatCompactCount(n: number): string { function PermLabel({ perm, t }: { perm: string; t: T }) { return PERM_KEYS.includes(perm) ? {t(`admin.plugins.perm.${perm}` as never)} - : {perm} + : {perm} +} + +/** A white box of short lines between hairlines, for the lists inside a dialog. */ +function DialogList({ children }: { children: ReactNode }) { + return
      {children}
    +} + +/** One line of a DialogList: an icon, what it is, and room for an action on the right. */ +function DialogListItem({ icon, children, trailing }: { icon?: ReactNode; children: ReactNode; trailing?: ReactNode }) { + return ( +
  • + {icon && {icon}} +
    {children}
    + {trailing &&
    {trailing}
    } +
  • + ) +} + +/** A warning line inside a dialog body: a soft amber box with its icon. */ +function WarnNote({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( +

    + {icon}{children} +

    + ) +} + +/** A raised tile for a dialog's head band. */ +function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) { + return {children} } /** @@ -1611,46 +1537,225 @@ function PermLabel({ perm, t }: { perm: string; t: T }) { function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: { copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T }) { - return createPortal( -
    -
    e.stopPropagation()}> -
    -
    - -
    -
    -

    {title}

    -

    {body}

    -
    -
    -
    + return ( + } tint={WARN_TINT} labelId="plugin-range-bypass-title" title={title} onClose={onClose} />} + footer={( + + {confirm ? ( <> - - + {t('common.cancel')} + {t('admin.plugins.installAnyway')} ) : ( - + {t('common.ok')} )} -
    -
    -
    , - document.body, + + )} + > +

    {body}

    + ) } -function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: { - item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean +/** What the runtime recorded for one plugin, newest as the server sends them. */ +function ErrorLogDialog({ id, rows, t, onClose }: { + id: string; rows: Array<{ ts: string; level: string; message: string }>; t: T; onClose: () => void +}) { + return ( + } tint={NEUTRAL_TINT} labelId="plugin-error-log-title" title={`${id} — ${t('admin.plugins.errorLog')}`} onClose={onClose} />} + > + {rows.length === 0 ? ( + {t('admin.plugins.noErrors')} + ) : ( +
    + {rows.map((r, i) => ( +
    + {r.level} + {r.ts} + {r.message} +
    + ))} +
    + )} +
    + ) +} + +/** The hosts an operatorEgress plugin may reach, added by the admin. Saving restarts it. */ +function EgressDialog({ data, draft, setDraft, saving, error, onSave, onClose, t }: { + data: { id: string; supported: boolean; hosts: string[] } + draft: string; setDraft: (v: string) => void; saving: boolean; error: string + onSave: (hosts: string[]) => void; onClose: () => void; t: T +}) { + return ( + } tint={NEUTRAL_TINT} labelId="plugin-egress-title" title={`${data.id} — ${t('admin.plugins.allowedHosts')}`} onClose={onClose} />} + > + {!data.supported ? ( + {t('admin.plugins.allowedHosts.unsupported')} + ) : ( + <> + {t('admin.plugins.allowedHosts.hint')} + {data.hosts.length === 0 ? ( +

    + {t('admin.plugins.allowedHosts.none')} +

    + ) : ( + + {data.hosts.map(h => ( + } + trailing={( + + + + )}> + {h} + + ))} + + )} +
    + setDraft(e.target.value)} + placeholder="gotify.example.com" + aria-label={t('admin.plugins.allowedHosts.add')} + className={`${INPUT} flex-1 font-mono`} + /> + +
    + {error &&

    {error}

    } + {t('admin.plugins.allowedHosts.restartNote')} + + )} +
    + ) +} + +/** Folds each run of consecutive checkbox fields into one array, keeping the field order. */ +function groupToggles(fields: F[]): Array { + const out: Array = [] + for (const f of fields) { + const last = out[out.length - 1] + if (f.input_type !== 'checkbox') out.push(f) + else if (Array.isArray(last)) last.push(f) + else out.push([f]) + } + return out +} + +/** The admin-owned scope:'instance' fields and actions of one plugin (logic in useInstanceSettings). */ +function InstanceSettingsDialog({ settings, t }: { settings: ReturnType; t: T }) { + const form = settings.form + if (!form) return null + const fieldLabel = (f: { key: string; label?: string; required?: boolean }) => ( + <>{f.label || f.key}{f.required && *} + ) + return ( + } tint={NEUTRAL_TINT} labelId="plugin-instance-settings-title" title={`${form.id} — ${t('admin.plugins.instanceSettings')}`} onClose={settings.close} />} + footer={( + + + {t('common.cancel')} + void settings.save()}> + {settings.saving && }{t('common.save')} + + + )} + > + {groupToggles(form.fields).map(f => { + if (Array.isArray(f)) { + // A run of consecutive checkbox fields shares one box of toggle rows. + return ( + + {f.map(c => ( + settings.setValue(c.key, form.values[c.key] !== true)} />} /> + ))} + + ) + } + const inputId = `plugin-setting-${f.key}` + if (f.input_type === 'select' && f.options) { + return ( + + settings.setValue(f.key, String(v))} + options={[{ value: '', label: '—' }, ...f.options.map(o => ({ value: o.value, label: o.label }))]} /> + + ) + } + return ( + + settings.setValue(f.key, e.target.value)} + className={INPUT} + /> + + ) + })} + {form.actions.length > 0 && ( + + {!form.active && {t('admin.plugins.actions.inactive')}} + + {form.actions.map(a => { + const res = settings.actionResult[a.key] + return ( +
    + + {a.hint && {a.hint}} + {res && ( + + {res.message || (res.ok ? t('common.success') : t('common.error'))} + + )} +
    + ) + })} +
    +
    + )} + {settings.error &&

    {settings.error}

    } +
    + ) +} + +function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange, blocked }: { + item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean; blocked: boolean onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string }) { const [detail, setDetail] = useState(null) @@ -1675,205 +1780,211 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local // The detail fetch carries the same compat verdict as the browse list; prefer it once // it lands (it is keyed to the same entry) and fall back to the grid item until then. const offer = installOffer(detail ?? item, t, ignoreTrekRange) + const access = caps.filter(c => !c.net) + const linkClass = SETTINGS_ICON_BUTTON return ( -
    -
    e.stopPropagation()}> -
    - -
    - -
    - -
    -
    - -
    -
    -
    -

    {item.name}

    - {item.reviewedAt && } -
    -

    {item.author}{item.latest ? ` · v${item.latest}` : ''}

    -
    - -
    - -
    -

    {item.description}

    - {failed &&

    {t('admin.plugins.detailError')}

    } - {/* The reason the button is blocked (or offering an older version) — a tooltip - alone would leave a touch user with a dead button and no explanation. */} - {offer.title && !installed && ( -

    - {offer.title} -

    + } + tint={NEUTRAL_TINT} + labelId="plugin-detail-title" + title={item.name} + sub={`${item.author}${item.latest ? ` · v${item.latest}` : ''}`} + pills={( + <> + + + {item.reviewedAt && }>{t('admin.plugins.reviewed')}} + )} - - {manifest && ( -
    -

    {t('admin.plugins.accessTitle')}

    - {caps.filter(c => !c.net).length === 0 && !permissions.includes('db:own') ? ( -

    {t('admin.plugins.noAccess')}

    - ) : ( -
    - {caps.filter(c => !c.net).map((c, i) => ( -
    - {c.label} -
    - ))} - {permissions.includes('db:own') && ( -
    - {t('admin.plugins.perm.db:own')} -
    - )} -
    - )} -
    - )} - - {/* The tool text a plugin publishes goes straight into every user's - assistant context once mcp:tools is granted, and nothing else in - this dialog shows it. An admin approving the grant should read the - names and descriptions first, not discover them in a chat. */} - {manifest && (manifest.capabilities?.mcpTools?.length ?? 0) > 0 && ( -
    -

    {t('admin.plugins.mcpToolsTitle')}

    -
      - {(manifest.capabilities?.mcpTools ?? []).map(tool => ( -
    • - -
      - {tool.name} - {tool.title &&

      {tool.title}

      } - {/* The description is the assistant-facing text: it is what the - model reads to decide whether to call the tool, so it is the - line an admin most needs to see. Never hidden behind a title. */} -

      {tool.description}

      -
      -
    • - ))} -
    -

    {t('admin.plugins.mcpToolsHint')}

    -
    - )} - - - - {manifest && (egress.length > 0 || manifest.operatorEgress) && ( -
    -

    {t('admin.plugins.connectsTitle')}

    -
    - {egress.map(h => ( - {h} - ))} - {/* The hosts above are NOT the whole story for this plugin: it talks to a - service only the operator can name, so its reach is whatever an admin - adds after install. Say so HERE — this is the pre-install review, and a - reviewer who reads only the host list would otherwise be misled. */} - {manifest.operatorEgress && ( - - {t('admin.plugins.operatorEgressPill')} - - )} -
    - {manifest.operatorEgress && ( -

    {t('admin.plugins.operatorEgressHint')}

    - )} -
    - )} - - {manifest && settings.length > 0 && ( -
    -

    {t('admin.plugins.setupTitle')}

    -
      - {settings.map(s => ( -
    • - {s.label} - {t(`admin.plugins.scope.${s.scope}` as never)} - {s.required && {t('admin.plugins.fieldRequired')}} -
    • - ))} -
    -
    - )} - -
    -

    {t('admin.plugins.detailsTitle')}

    -
    - {item.latest && } - {sizeKb !== null && } - {/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything - newer", which is exactly the claim a `<4.0.0` upper bound denies. */} - {(item.trek || item.minTrekVersion) && ( - - )} - {item.reviewedAt && } - {typeof item.downloadCount === 'number' && item.downloadCount > 0 && ( - - )} -
    -
    - - {/* Every published version, installable individually. The compat verdict per - version is SERVER-computed — an incompatible one is explained, never offered. */} - {(detail?.versions?.length ?? 0) > 1 && ( -
    -

    {t('admin.plugins.versionsTitle')}

    -
    - {detail!.versions!.map(v => ( -
    - v{v.version} - {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} - {v.signed && } - - {v.compatible ? ( - !installed && ( - - ) - ) : ( - {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} - )} -
    - ))} -
    -
    - )} -
    - -
    - - {t('admin.plugins.sourceRepo')} - - - {t('admin.plugins.reportIssue')} - + onClose={onClose} + /> + )} + footer={( + + + + + + + {homepage && ( - - {t('admin.plugins.homepage')} - + + + + )} + + onInstall(item.id, offer.version, offer.warn)} + disabled={busy === item.id || installed || offer.blocked} + title={installed ? undefined : offer.title}> + {busy === item.id ? : } + {installed ? t('admin.plugins.installed') : offer.label} + + + )} + > + {item.screenshotUrl && } + +
    +

    {item.description}

    + {failed &&

    {t('admin.plugins.detailError')}

    } + {/* The reason the button is blocked (or offering an older version) — a tooltip + alone would leave a touch user with a dead button and no explanation. */} + {offer.title && !installed && }>{offer.title}} +
    + + {!detail && !failed && ( +
    + {t('common.loading')} +
    + )} + + {manifest && ( + + {access.length === 0 && !permissions.includes('db:own') ? ( + {t('admin.plugins.noAccess')} + ) : ( + + {access.map((c, i) => ( + }>{c.label} + ))} + {permissions.includes('db:own') && ( + }>{t('admin.plugins.perm.db:own')} + )} + + )} + + )} + + {/* The tool text a plugin publishes goes straight into every user's + assistant context once mcp:tools is granted, and nothing else in + this dialog shows it. An admin approving the grant should read the + names and descriptions first, not discover them in a chat. */} + {manifest && (manifest.capabilities?.mcpTools?.length ?? 0) > 0 && ( + + + {(manifest.capabilities?.mcpTools ?? []).map(tool => ( + }> + {tool.name} + {tool.title &&

    {tool.title}

    } + {/* The description is the assistant-facing text: it is what the + model reads to decide whether to call the tool, so it is the + line an admin most needs to see. Never hidden behind a title. */} +

    {tool.description}

    +
    + ))} +
    + {t('admin.plugins.mcpToolsHint')} +
    + )} + + + + {manifest && (egress.length > 0 || manifest.operatorEgress) && ( + +
    + {egress.map(h => ( + {h} + ))} + {/* The hosts above are NOT the whole story for this plugin: it talks to a + service only the operator can name, so its reach is whatever an admin + adds after install. Say so HERE — this is the pre-install review, and a + reviewer who reads only the host list would otherwise be misled. */} + {manifest.operatorEgress && ( + }>{t('admin.plugins.operatorEgressPill')} + )} +
    + {manifest.operatorEgress && {t('admin.plugins.operatorEgressHint')}} +
    + )} + + {manifest && settings.length > 0 && ( + + + {settings.map(s => ( + + {t(`admin.plugins.scope.${s.scope}` as never)} + {s.required && {t('admin.plugins.fieldRequired')}} + + )}> + {s.label} + + ))} + + + )} + + +
    + {item.latest && } + {sizeKb !== null && } + {/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything + newer", which is exactly the claim a `<4.0.0` upper bound denies. */} + {(item.trek || item.minTrekVersion) && ( + + )} + {item.reviewedAt && } + {typeof item.downloadCount === 'number' && item.downloadCount > 0 && ( + )}
    -
    -
    + + + {/* Every published version, installable individually. The compat verdict per + version is SERVER-computed — an incompatible one is explained, never offered. */} + {(detail?.versions?.length ?? 0) > 1 && ( + + + {detail!.versions!.map(v => ( + onInstall(item.id, v.version)} disabled={busy === item.id} + className={SMALL_BUTTON} style={fs(11.5, 'body')}> + {t('admin.plugins.installCompatible', { version: v.version })} + + ) + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )}> + + + ))} + + + )} + + ) +} + +/** A version number with its date and, when the author signed it, a quiet tick. */ +function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) { + return ( + + v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + ) } function Meta({ k, v }: { k: string; v: string }) { - return
    {k}
    {v}
    + return ( +
    +
    {k}
    +
    {v}
    +
    + ) } /** @@ -1887,41 +1998,42 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick t: T; locale: string; onPick: (version: string) => void; onClose: () => void }) { return ( -
    -
    e.stopPropagation()}> -
    -

    {t('admin.plugins.versionPickerTitle', { name: plugin.name })}

    - + } tint={NEUTRAL_TINT} labelId="plugin-version-picker-title" title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose} />} + > + {failed &&

    {t('admin.plugins.detailError')}

    } + {!failed && !versions && ( +
    + {t('common.loading')}
    -
    - {failed &&

    {t('admin.plugins.detailError')}

    } - {!failed && !versions &&

    {t('common.loading')}

    } - {versions?.length === 0 &&

    {t('admin.plugins.noVersions')}

    } - {versions?.map(v => { + )} + {versions?.length === 0 && {t('admin.plugins.noVersions')}} + {versions && versions.length > 0 && ( + + {versions.map(v => { const current = v.version === plugin.version return ( -
    - v{v.version} - {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} - {v.signed && } - +
    +
    {current ? ( - {t('admin.plugins.installed')} + }>{t('admin.plugins.installed')} ) : v.compatible ? ( ) : ( - {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} )}
    ) })} -
    -
    -
    + + )} + ) } @@ -1959,51 +2071,47 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { : 'admin.plugins.sig.invalidBody' return ( -
    -
    e.stopPropagation()}> -
    -
    -
    -

    {t('admin.plugins.sig.title', { name: data.subject.name })}

    -

    {t(bodyKey as never)}

    -
    -
    -
    - {/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the - new one back to the author over the phone. The full key travels in the request. */} - {canRetrust && ( -
    - - -

    {t('admin.plugins.sig.confirmOutOfBand')}

    -
    - )} - {!canRetrust && data.detail && ( -

    {data.detail}

    - )} -
    -
    - + } tint={WARN_TINT} labelId="plugin-signature-title" title={t('admin.plugins.sig.title', { name: data.subject.name })} onClose={onClose} />} + footer={( + + + {offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')} {offerRetrust && ( - + onRetrust(version, newKey)} disabled={busy}> + {busy && }{t('admin.plugins.sig.retrustConfirm')} + )} + + )} + > +

    {t(bodyKey as never)}

    + {/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the + new one back to the author over the phone. The full key travels in the request. */} + {canRetrust && ( +
    + + + + + {t('admin.plugins.sig.confirmOutOfBand')}
    -
    -
    + )} + {!canRetrust && data.detail && ( +

    {data.detail}

    + )} + ) } function KeyRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { return ( -
    - {label} - {value} +
    + {label} + {value}
    ) } @@ -2023,50 +2131,47 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: { t: T; onApprove: () => void; onLater: () => void }) { return ( -
    -
    e.stopPropagation()}> -
    -
    -
    -

    {t('admin.plugins.updateConsentTitle')}

    -

    {t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}

    + } tint={WARN_TINT} labelId="plugin-consent-title" + title={t('admin.plugins.updateConsentTitle')} + sub={t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })} subWraps + onClose={onLater} /> + )} + footer={( + + + {t('admin.plugins.updateLater')} + {t('admin.plugins.updateApprove')} + + )} + > + {/* The admin is about to widen what this code may do — so say, right here, that + nothing ties this code to its author. One line, no checkbox, no extra click: + this informs, it does not block. */} + {unsigned && }>{t('admin.plugins.sig.consentUnsigned')}} + {data.newPermissions.length > 0 && ( + + + {data.newPermissions.map(perm => ( + }> + ))} + + + )} + {data.newEgress.length > 0 && ( + +
    + {data.newEgress.map(host => ( + {host} + ))}
    -
    -
    - {/* The admin is about to widen what this code may do — so say, right here, that - nothing ties this code to its author. One line, no checkbox, no extra click: - this informs, it does not block. */} - {unsigned && ( -

    - - {t('admin.plugins.sig.consentUnsigned')} -

    - )} - {data.newPermissions.length > 0 && ( -
    -

    {t('admin.plugins.updateNewPermissions')}

    -
      - {data.newPermissions.map(perm => ( -
    • - ))} -
    -
    - )} - {data.newEgress.length > 0 && ( -
    -

    {t('admin.plugins.updateNewEgress')}

    -
    - {data.newEgress.map(host => {host})} -
    -
    - )} -
    -
    - - -
    -
    -
    + + )} + ) } @@ -2083,41 +2188,46 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })), ] return ( -
    -
    e.stopPropagation()}> -
    -
    -
    -

    {t('admin.plugins.dep.resolveTitle')}

    -

    {t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}

    -
    -
    -
    - {rows.map(r => ( -
    -
    -
    {r.id}
    -
    - {r.installed - ? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed }) - : t('admin.plugins.dep.requires', { version: r.constraint })} -
    + } tint={WARN_TINT} labelId="plugin-deps-title" + title={t('admin.plugins.dep.resolveTitle')} + sub={t('admin.plugins.dep.resolveBody', { name: data.plugin.name })} subWraps + onClose={onClose} /> + )} + footer={( + + + {t('common.cancel')} + + )} + > + + {rows.map(r => ( +
    + + + +
    +
    {r.id}
    +
    + {r.installed + ? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed }) + : t('admin.plugins.dep.requires', { version: r.constraint })}
    -
    - ))} - {rows.some(r => !installedIds.has(r.id)) && ( -

    {t('admin.plugins.dep.resolveHint')}

    - )} -
    -
    - -
    -
    -
    + +
    + ))} + + {rows.some(r => !installedIds.has(r.id)) && {t('admin.plugins.dep.resolveHint')}} + ) } @@ -2135,26 +2245,30 @@ function SecurityInfo({ t }: { t: T }) { ['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'], ] return ( -
    -
    - -

    {t('admin.plugins.reviewedMeaning')}

    + setOpen(o => !o)} aria-expanded={open} + className="inline-flex max-w-full items-center gap-1.5 text-left font-bold text-content hover:opacity-80"> + {t('admin.plugins.security.title')} + + + )} + > +
    + + {t('admin.plugins.reviewedMeaning')}
    - {open && ( -
    +
    {sections.map(([h, b]) => ( -
    -

    {t(h as never)}

    -

    {t(b as never)}

    +
    +

    {t(h as never)}

    +

    {t(b as never)}

    ))}
    )} -
    + ) } diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx index 868193b98..c3c73cb85 100644 --- a/client/src/components/Admin/AuditLogPanel.tsx +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -1,7 +1,9 @@ import React, { useCallback, useEffect, useState } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' -import { RefreshCw, ClipboardList } from 'lucide-react' +import { RefreshCw, ClipboardList, Loader2 } from 'lucide-react' +import { fs } from '../shared/DialogShell' +import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit' interface AuditEntry { id: number @@ -19,6 +21,10 @@ interface AuditLogPanelProps { serverTimezone?: string } +const TH = 'whitespace-nowrap px-3.5 py-2.5 text-left font-geist font-bold uppercase tracking-[.08em] text-content-faint' +const TD = 'px-3.5 py-2.5 align-top' +const CODE = 'font-geist tabular-nums' + export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement { const { t, locale } = useTranslation() const [entries, setEntries] = useState([]) @@ -96,74 +102,86 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R return '—' } + let body: React.ReactNode + if (loading && entries.length === 0) { + body = ( +
    + + {t('common.loading')} +
    + ) + } else if (entries.length === 0) { + body = {t('admin.audit.empty')} + } else { + body = ( +
    + + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + ))} + +
    {t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')}
    {fmtTime(e.created_at)} + {/* A table cell ignores max-width under auto layout, so the box inside truncates. */} + {userLabel(e)} + + {e.action} + {e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)}
    +
    + ) + } + return ( -
    -
    -
    -

    - - {t('admin.tabs.audit')} -

    -

    {t('admin.audit.subtitle')}

    -
    + {t('admin.audit.showing', { count: entries.length, total })}} + action={ -
    - -

    - {t('admin.audit.showing', { count: entries.length, total })} -

    - - {loading && entries.length === 0 ? ( -
    {t('common.loading')}
    - ) : entries.length === 0 ? ( -
    {t('admin.audit.empty')}
    - ) : ( -
    - - - - - - - - - - - - - {entries.map((e) => ( - - - - - - - - - ))} - -
    {t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')}
    {fmtTime(e.created_at)}{userLabel(e)}{e.action}{e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)}
    -
    - )} + } + > + {body} {entries.length < total && ( - +
    + +
    )} -
    + ) } diff --git a/client/src/components/Admin/BackupPanel.test.tsx b/client/src/components/Admin/BackupPanel.test.tsx index 77f4b7dca..db30a6a05 100644 --- a/client/src/components/Admin/BackupPanel.test.tsx +++ b/client/src/components/Admin/BackupPanel.test.tsx @@ -165,8 +165,8 @@ describe('BackupPanel', () => { await waitFor(() => { expect(screen.getByText('Restore Backup?')).toBeInTheDocument() }) - // Click the backdrop overlay (the fixed-position div) - const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement + // Click the backdrop overlay (the shared confirm dialog's dimmed layer) + const backdrop = document.querySelector('.trek-backdrop-enter') as HTMLElement expect(backdrop).toBeTruthy() fireEvent.click(backdrop!) await waitFor(() => { @@ -215,6 +215,10 @@ describe('BackupPanel', () => { ) as HTMLElement expect(trashBtn).toBeTruthy() await user.click(trashBtn!) + // The question is the shared confirm dialog now, not window.confirm + expect(await screen.findByText('Delete backup "backup-2025-01-15.zip"?')).toBeInTheDocument() + const deleteButtons = screen.getAllByRole('button', { name: 'Delete' }) + await user.click(deleteButtons[deleteButtons.length - 1]!) await waitFor(() => { expect(screen.getByText('Backup deleted')).toBeInTheDocument() }) @@ -421,7 +425,6 @@ describe('BackupPanel', () => { return HttpResponse.json({ error: 'file is locked' }, { status: 500 }) }), ) - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) render(<>) await screen.findByText('backup-2025-01-15.zip') @@ -429,11 +432,13 @@ describe('BackupPanel', () => { b => b.querySelector('svg.lucide-trash2'), ) as HTMLElement await user.click(trashBtn) + await user.click(await screen.findByRole('button', { name: 'Cancel' })) expect(deleteCalls).toBe(0) expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument() - confirmSpy.mockReturnValue(true) await user.click(trashBtn) + const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' }) + await user.click(deleteButtons[deleteButtons.length - 1]!) expect(await screen.findByText('Failed to delete')).toBeInTheDocument() expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument() @@ -584,18 +589,24 @@ describe('BackupPanel', () => { expect(await screen.findByText('Download failed')).toBeInTheDocument() }) - // BKP-029: Confirm button hover styling - it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => { + // BKP-029: Escape takes the restore question back + it('FE-ADMIN-BKP-029: Escape dismisses the restore question without restoring', async () => { const user = userEvent.setup() + let restoreCalled = false + server.use( + http.post('/api/backup/restore/:filename', () => { + restoreCalled = true + return HttpResponse.json({ success: true }) + }), + ) render() await screen.findByText('backup-2025-01-15.zip') await user.click(screen.getAllByText('Restore')[0]) - const confirmBtn = await screen.findByText('Yes, restore') + await screen.findByText('Yes, restore') - fireEvent.mouseEnter(confirmBtn) - expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)') - fireEvent.mouseLeave(confirmBtn) - expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)') + await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()) + expect(restoreCalled).toBe(false) }) }) diff --git a/client/src/components/Admin/BackupPanel.tsx b/client/src/components/Admin/BackupPanel.tsx index 90b6896bc..bfca2f5ff 100644 --- a/client/src/components/Admin/BackupPanel.tsx +++ b/client/src/components/Admin/BackupPanel.tsx @@ -1,10 +1,26 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, type ChangeEvent } from 'react' import { backupApi } from '../../api/client' import { useToast } from '../shared/Toast' -import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react' +import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, Loader2 } from 'lucide-react' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import CustomSelect from '../shared/CustomSelect' +import ConfirmDialog from '../shared/ConfirmDialog' +import EmptyState from '../shared/EmptyState' +import { Tooltip } from '../shared/Tooltip' +import { fs } from '../shared/DialogShell' +import { Segmented } from '../shared/dialogParts' +import ToggleSwitch from '../Settings/ToggleSwitch' +import { + ChoiceChips, + SETTINGS_BUTTON, + SETTINGS_BUTTON_PRIMARY, + SETTINGS_ICON_BUTTON, + SettingRow, + SettingRows, + SettingsCard, + StatusPill, +} from '../Settings/settingsKit' import { getApiErrorMessage } from '../../types' const INTERVAL_OPTIONS = [ @@ -37,20 +53,36 @@ const HOURS = Array.from({ length: 24 }, (_, i) => i) const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1) +/** The compact buttons of a backup row: white on a hairline, a size under the card's own. */ +const ROW_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50' + +interface BackupItem { + filename: string + created_at?: string | null + size?: number | null +} + +interface RestoreTarget { + type: 'file' | 'upload' + filename: string + file?: File +} + export default function BackupPanel() { - const [backups, setBackups] = useState([]) + const [backups, setBackups] = useState([]) const [isLoading, setIsLoading] = useState(false) const [isCreating, setIsCreating] = useState(false) - const [restoringFile, setRestoringFile] = useState(null) + const [restoringFile, setRestoringFile] = useState(null) const [isUploading, setIsUploading] = useState(false) const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }) const [autoSettingsSaving, setAutoSettingsSaving] = useState(false) const [autoSettingsDirty, setAutoSettingsDirty] = useState(false) const [serverTimezone, setServerTimezone] = useState('') - const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? } - const fileInputRef = useRef(null) + const [restoreConfirm, setRestoreConfirm] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const fileInputRef = useRef(null) const toast = useToast() - const { t, language, locale } = useTranslation() + const { t, locale } = useTranslation() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const loadBackups = async () => { @@ -88,12 +120,12 @@ export default function BackupPanel() { } } - const handleRestore = (filename) => { + const handleRestore = (filename: string) => { setRestoreConfirm({ type: 'file', filename }) } - const handleUploadRestore = (e) => { - const file = (e.target as HTMLInputElement).files?.[0] + const handleUploadRestore = (e: ChangeEvent) => { + const file = e.target.files?.[0] if (!file) return e.target.value = '' setRestoreConfirm({ type: 'upload', filename: file.name, file }) @@ -127,8 +159,15 @@ export default function BackupPanel() { } } - const handleDelete = async (filename) => { - if (!confirm(t('backup.confirm.delete', { name: filename }))) return + // The question is asked in the planner's confirm dialog; the delete itself runs once it is answered. + const handleDelete = (filename: string) => { + setDeleteTarget(filename) + } + + const executeDelete = async () => { + const filename = deleteTarget + setDeleteTarget(null) + if (!filename) return try { await backupApi.delete(filename) toast.success(t('backup.toast.deleted')) @@ -138,7 +177,7 @@ export default function BackupPanel() { } } - const handleAutoSettingsChange = (key, value) => { + const handleAutoSettingsChange = (key: string, value: unknown) => { setAutoSettings(prev => ({ ...prev, [key]: value })) setAutoSettingsDirty(true) } @@ -157,13 +196,13 @@ export default function BackupPanel() { } } - const formatSize = (bytes) => { + const formatSize = (bytes: number | null | undefined) => { if (!bytes) return '-' if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / 1024 / 1024).toFixed(1)} MB` } - const formatDate = (dateStr) => { + const formatDate = (dateStr: string | null | undefined) => { if (!dateStr) return '-' try { const opts: Intl.DateTimeFormatOptions = { @@ -175,352 +214,315 @@ export default function BackupPanel() { } catch { return dateStr } } - const isAuto = (filename) => filename.startsWith('auto-backup-') + const isAuto = (filename: string) => filename.startsWith('auto-backup-') - return ( -
    + const headerActions = ( + <> + + + - {/* Manual Backups */} -
    -
    -
    - -
    -

    {t('backup.title')}

    -

    {t('backup.subtitle')}

    -
    -
    -
    - + {/* Upload & Restore */} + + - {/* Upload & Restore */} - - + + + ) - -
    -
    - - {isLoading && backups.length === 0 ? ( -
    -
    - {t('common.loading')} -
    - ) : backups.length === 0 ? ( -
    - -

    {t('backup.empty')}

    - -
    - ) : ( -
    - {backups.map(backup => ( -
    -
    - {isAuto(backup.filename) - ? - : - } + let backupList + if (isLoading && backups.length === 0) { + backupList = ( +
    + + {t('common.loading')} +
    + ) + } else if (backups.length === 0) { + backupList = ( + + + {t('backup.createFirst')} + + } + /> + ) + } else { + backupList = ( + + {backups.map(backup => { + const auto = isAuto(backup.filename) + const restoring = restoringFile === backup.filename + return ( +
    + + {auto + ? + : } + +
    +
    +

    {backup.filename}

    + {auto && Auto}
    -
    -
    -

    {backup.filename}

    - {isAuto(backup.filename) && ( - Auto - )} -
    -
    - {formatDate(backup.created_at)} - {formatSize(backup.size)} -
    -
    -
    - - - +
    + {formatDate(backup.created_at)} + + {formatSize(backup.size)}
    - ))} -
    - )} -
    +
    + + + + + +
    +
    + ) + })} + + ) + } + + const hourOptions = HOURS.map(h => { + let label: string + if (is12h) { + const period = h >= 12 ? 'PM' : 'AM' + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h + label = `${h12}:00 ${period}` + } else { + label = `${String(h).padStart(2, '0')}:00` + } + return { value: String(h), label } + }) + + return ( +
    + + {/* Manual Backups */} + + {backupList} + {/* Auto-Backup Settings */} -
    -
    - -
    -

    {t('backup.auto.title')}

    -

    {t('backup.auto.subtitle')}

    -
    -
    - -
    - {/* Enable toggle */} -
    + } + /> )} {/* Keep duration */} -
    - -
    - {KEEP_OPTIONS.map(opt => ( - - ))} -
    -
    + + label={t('backup.auto.keepLabel')} + value={String(autoSettings.keep_days)} + onChange={v => handleAutoSettingsChange('keep_days', Number(v))} + options={KEEP_OPTIONS.map(opt => ({ value: String(opt.value), label: t(opt.labelKey) }))} + /> + } + /> )} + - {/* Save button */} -
    - -
    -
    -
    - - {/* Restore Warning Modal */} - {restoreConfirm && ( -
    setRestoreConfirm(null)} - > -
    e.stopPropagation()} - style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }} - className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700" + {/* Save button */} +
    + - -
    -
    + {autoSettingsSaving + ? + : } + {autoSettingsSaving ? t('common.saving') : t('common.save')} +
    - )} + + + {/* Restore warning */} + setRestoreConfirm(null)} + onConfirm={executeRestore} + title={t('backup.restoreConfirmTitle')} + message={t('backup.restoreWarning')} + confirmLabel={t('backup.restoreConfirm')} + danger + > + {restoreConfirm && ( + <> +
    + + {restoreConfirm.filename} +
    +

    + {t('backup.restoreTip')} +

    + + )} +
    + + {/* Delete question */} + setDeleteTarget(null)} + onConfirm={executeDelete} + title={t('common.delete')} + message={deleteTarget ? t('backup.confirm.delete', { name: deleteTarget }) : ''} + confirmLabel={t('common.delete')} + danger + />
    ) } diff --git a/client/src/components/Admin/CategoryManager.tsx b/client/src/components/Admin/CategoryManager.tsx index 67b072eb3..0ec10e9db 100644 --- a/client/src/components/Admin/CategoryManager.tsx +++ b/client/src/components/Admin/CategoryManager.tsx @@ -1,7 +1,10 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useId } from 'react' import { categoriesApi } from '../../api/client' import { useToast } from '../shared/Toast' -import { Plus, Edit2, Trash2, Pipette } from 'lucide-react' +import { Plus, Pencil, Trash2, Pipette, Tags, Loader2 } from 'lucide-react' +import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' +import { Tooltip } from '../shared/Tooltip' +import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit' import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' import { getApiErrorMessage } from '../../types' @@ -12,18 +15,22 @@ const PRESET_COLORS = [ '#6b7280', '#1f2937', ] +// The colour a new category is stored with: data the server keeps, not a theme colour. +const DEFAULT_COLOR = '#6366f1' // theme-lint-disable: a stored category colour, not styling + const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP) export default function CategoryManager() { const [categories, setCategories] = useState([]) const [showForm, setShowForm] = useState(false) const [editingId, setEditingId] = useState(null) - const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' }) + const [form, setForm] = useState({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' }) const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) const colorInputRef = useRef(null) const toast = useToast() const { t } = useTranslation() + const labelId = useId() useEffect(() => { void loadCategories() }, []) @@ -41,13 +48,13 @@ export default function CategoryManager() { const handleStartEdit = (cat) => { setEditingId(cat.id) - setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' }) + setForm({ name: cat.name, color: cat.color || DEFAULT_COLOR, icon: cat.icon || 'MapPin' }) setShowForm(false) } const handleStartCreate = () => { setEditingId(null) - setForm({ name: '', color: '#6366f1', icon: 'MapPin' }) + setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' }) setShowForm(true) } @@ -71,7 +78,7 @@ export default function CategoryManager() { setShowForm(false) toast.success(t('categories.toast.created')) } - setForm({ name: '', color: '#6366f1', icon: 'MapPin' }) + setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' }) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('categories.toast.saveError'))) } finally { @@ -92,22 +99,44 @@ export default function CategoryManager() { const isPresetColor = PRESET_COLORS.includes(form.color) const PreviewIcon = getCategoryIcon(form.icon) + const formOpen = showForm || editingId !== null + const saveDisabled = isSaving || !form.name.trim() - const categoryForm = ( -
    - setForm(prev => ({ ...prev, name: e.target.value }))} - placeholder={t('categories.namePlaceholder')} - className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white" - autoFocus - /> - -
    - -
    -
    + const categoryDialog = ( + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={handleCancel} + eyebrow={editingId ? t('common.edit') : t('categories.new')} + titleInput={{ + value: form.name, + onChange: name => setForm(prev => ({ ...prev, name })), + label: t('categories.namePlaceholder'), + placeholder: t('categories.namePlaceholder'), + autoFocus: true, + onKeyDown: e => { if (e.key === 'Enter' && !saveDisabled) void handleSave() }, + }} + /> + } + footer={ + + + {t('common.cancel')} + + {isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')} + + + } + > + +
    +
    {ICON_NAMES.map(name => { const Icon = CATEGORY_ICON_MAP[name] const isSelected = form.icon === name @@ -116,28 +145,29 @@ export default function CategoryManager() { key={name} type="button" title={ICON_LABELS[name] || name} + aria-label={ICON_LABELS[name] || name} + aria-pressed={isSelected} onClick={() => setForm(prev => ({ ...prev, icon: name }))} - className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${ - isSelected - ? 'ring-2 ring-offset-1 ring-slate-700' - : 'hover:bg-gray-200' + className={`grid h-[38px] place-items-center rounded-[10px] transition-colors ${ + isSelected ? 'bg-surface-card shadow-sm ring-2 ring-content' : 'text-content-secondary hover:bg-surface-card' }`} - style={{ background: isSelected ? `${form.color}18` : undefined }} + style={isSelected ? { background: `${form.color}18` } : undefined} > - + ) })}
    -
    + -
    - -
    + +
    {PRESET_COLORS.map(color => (
    -
    + -
    - {t('categories.preview')}: - - - {form.name || t('categories.defaultName')} - -
    - -
    - - -
    -
    + +
    + + + {form.name || t('categories.defaultName')} + +
    +
    + ) return ( -
    -
    -
    -

    {t('categories.title')}

    -

    {t('categories.subtitle')}

    -
    - -
    - - {showForm &&
    {categoryForm}
    } - + } + > {isLoading ? ( -
    -
    +
    +
    ) : categories.length === 0 ? ( -
    -

    {t('categories.empty')}

    +
    + {t('categories.empty')}
    ) : ( -
    + {categories.map(cat => { const Icon = getCategoryIcon(cat.icon) return ( -
    - {editingId === cat.id ? ( -
    {categoryForm}
    - ) : ( -
    -
    - -
    -
    -
    - {cat.name} - - {cat.color} - -
    -
    -
    - - -
    -
    - )} +
    + + + +
    + {cat.name} + + {cat.color} + +
    +
    + + + + + + +
    ) })} -
    +
    )} -
    + {formOpen && categoryDialog} + ) } diff --git a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx index 95a5e8d3c..e52b0160b 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx @@ -43,9 +43,9 @@ function withToast() { return render(<>); } -/** The selected option button is the one drawn with the strong border token. */ +/** The selected option is the pressed button of its segmented group. */ function isActive(button: HTMLElement): boolean { - return (button.style.border || '').includes('var(--text-primary)'); + return button.getAttribute('aria-pressed') === 'true'; } /** diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index 7a15ccd6c..84a2d200c 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -1,9 +1,12 @@ import React, { useEffect, useMemo, useState } from 'react' -import { Map as MapIcon, Settings2 } from 'lucide-react' +import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import Section from '../Settings/Section' +import { SettingRows, SettingsHint } from '../Settings/settingsKit' +import { fs } from '../shared/DialogShell' +import { EditorField, INPUT, Segmented } from '../shared/dialogParts' import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants' @@ -66,51 +69,49 @@ function styleForProvider(provider: GlMapProvider, style?: string | null): strin return normalizeStyleForProvider(provider, style) } -function OptionRow({ +/** + * One default as a row of the card's white box: its name (with the reset link + * once it is set) on the left, the choice on the right. `stacked` puts a wide + * choice and a hint under the name. The label sits straight in the row, so the + * row is the nearest box around both the name and its choice. + */ +function ChoiceRow({ label, hint, + stacked = false, children, }: { label: React.ReactNode hint?: string + stacked?: boolean children: React.ReactNode }) { return ( -
    -