- Installed on the server
-
+ Installed on the server
+
+
+ style={fs(11.5, 'body')}
+ >
{loadingModels ? 'Loading…' : 'Refresh'}
- {modelsErr &&
{modelsErr}
}
+ {modelsErr && (
+
+ {modelsErr}
+
+ )}
{!modelsErr && installed.length === 0 && !loadingModels && (
No models installed yet — pull one below.
)}
{installed.length > 0 && (
- {installed.map(name => (
-
(
+ setModel(name)}
@@ -541,22 +693,38 @@ 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
+
+ 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.label}
+
{m.recommended &&
Recommended }
{installedHere ? (
-
setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
+ setModel(m.id)}
+ disabled={active}
+ className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`}
+ style={fs(12, 'body')}
+ >
{active ? 'Selected' : 'Use'}
) : (
- pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
+ pull(m.id)}
+ disabled={!!pulling}
+ className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`}
+ style={fs(12, 'body')}
+ >
{isPulling ? 'Pulling…' : 'Pull'}
)}
@@ -564,36 +732,47 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
{isPulling && (
+
+ {pullStatus}
+ {pullPct ? ` · ${pullPct}%` : ''}
-
{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
)}
- )
+ );
})}
)}
-
+
{saving ? 'Saving…' : 'Save'}
- )
+ );
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
- const nameKey = `admin.addons.catalog.${addon.id}.name`
- const descKey = `admin.addons.catalog.${addon.id}.description`
- const translatedName = t(nameKey)
- const translatedDescription = t(descKey)
+ const nameKey = `admin.addons.catalog.${addon.id}.name`;
+ const descKey = `admin.addons.catalog.${addon.id}.description`;
+ const translatedName = t(nameKey);
+ const translatedDescription = t(descKey);
return {
name: translatedName !== nameKey ? translatedName : addon.name,
description: translatedDescription !== descKey ? translatedDescription : addon.description,
- }
+ };
}
-
diff --git a/client/src/components/Admin/AddonSubRow.tsx b/client/src/components/Admin/AddonSubRow.tsx
index 5a2829f7e..6a3a4f793 100644
--- a/client/src/components/Admin/AddonSubRow.tsx
+++ b/client/src/components/Admin/AddonSubRow.tsx
@@ -1,6 +1,6 @@
-import type { ReactNode } from 'react'
-import ToggleSwitch from '../Settings/ToggleSwitch'
-import { fs } from '../shared/DialogShell'
+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
@@ -23,21 +23,31 @@ export default function AddonSubRow({
onToggle,
}: {
/** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */
- icon?: ReactNode
- title: string
- description?: string
- enabled: boolean
- onToggle: () => void
+ icon?: ReactNode;
+ title: string;
+ description?: string;
+ enabled: boolean;
+ 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 da39734ec..ce78ebf5a 100644
--- a/client/src/components/Admin/AddonTile.tsx
+++ b/client/src/components/Admin/AddonTile.tsx
@@ -1,6 +1,6 @@
-import type { ReactNode } from 'react'
-import ToggleSwitch from '../Settings/ToggleSwitch'
-import { fs } from '../shared/DialogShell'
+import type { ReactNode } from 'react';
+import ToggleSwitch from '../Settings/ToggleSwitch';
+import { fs } from '../shared/DialogShell';
/**
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
@@ -23,13 +23,13 @@ export default function AddonTile({
onToggle,
children,
}: {
- icon: ReactNode
- name: string
- description?: string
- enabled: boolean
- onToggle: () => void
+ icon: ReactNode;
+ name: string;
+ description?: string;
+ enabled: boolean;
+ onToggle: () => void;
/** The sub-shelf: `` children. */
- children?: ReactNode
+ children?: ReactNode;
}) {
return (
@@ -38,7 +38,9 @@ export default function AddonTile({
// overflow-hidden so a brand mark that fills the slot keeps the slot's
// rounded corners instead of squaring them off.
className={`grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
- enabled ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
+ enabled
+ ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
+ : 'bg-surface-tertiary text-content-faint'
}`}
>
{icon}
@@ -70,5 +72,5 @@ export default function AddonTile({
)}
- )
+ );
}
diff --git a/client/src/components/Admin/AdminMcpTokensPanel.test.tsx b/client/src/components/Admin/AdminMcpTokensPanel.test.tsx
index f6fe60bba..609d66c35 100644
--- a/client/src/components/Admin/AdminMcpTokensPanel.test.tsx
+++ b/client/src/components/Admin/AdminMcpTokensPanel.test.tsx
@@ -1,8 +1,8 @@
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
-import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
+import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
@@ -39,7 +39,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/mcp-tokens', async () => {
- await new Promise(resolve => setTimeout(resolve, 200));
+ await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ tokens: [] });
})
);
@@ -53,11 +53,7 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
- server.use(
- http.get('/api/admin/mcp-tokens', () =>
- HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
- )
- );
+ server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(
);
await screen.findByText('CI Token');
expect(screen.getByText('Ops Token')).toBeInTheDocument();
@@ -69,11 +65,7 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
- server.use(
- http.get('/api/admin/mcp-tokens', () =>
- HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
- )
- );
+ server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(
);
await screen.findByText('CI Token');
expect(screen.getByText('Never')).toBeInTheDocument();
@@ -81,11 +73,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
const user = userEvent.setup();
- server.use(
- http.get('/api/admin/mcp-tokens', () =>
- HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
- )
- );
+ server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(
);
await screen.findByText('CI Token');
@@ -100,11 +88,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
const user = userEvent.setup();
- server.use(
- http.get('/api/admin/mcp-tokens', () =>
- HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
- )
- );
+ server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(
);
await screen.findByText('CI Token');
@@ -121,11 +105,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
const user = userEvent.setup();
- server.use(
- http.get('/api/admin/mcp-tokens', () =>
- HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
- )
- );
+ server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(
);
await screen.findByText('CI Token');
@@ -145,14 +125,15 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/mcp-tokens', () =>
- HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
- ),
- http.delete('/api/admin/mcp-tokens/:id', () =>
- HttpResponse.json({ success: true })
- )
+ http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
+ http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ success: true }))
+ );
+ render(
+ <>
+
+
+ >
);
- render(<>
>);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByLabelText('Delete');
@@ -170,14 +151,15 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/mcp-tokens', () =>
- HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
- ),
- http.delete('/api/admin/mcp-tokens/:id', () =>
- HttpResponse.json({ error: 'forbidden' }, { status: 403 })
- )
+ http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
+ http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
+ );
+ render(
+ <>
+
+
+ >
);
- render(<>
>);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByLabelText('Delete');
@@ -189,19 +171,20 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
- server.use(
- http.get('/api/admin/mcp-tokens', () =>
- HttpResponse.json({ error: 'server error' }, { status: 500 })
- )
+ server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
+ render(
+ <>
+
+
+ >
);
- render(<>
>);
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/oauth-sessions', async () => {
- await new Promise(resolve => setTimeout(resolve, 200));
+ await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ sessions: [] });
})
);
@@ -210,11 +193,7 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
- server.use(
- http.get('/api/admin/oauth-sessions', () =>
- HttpResponse.json({ sessions: [] })
- )
- );
+ server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [] })));
render(
);
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
});
@@ -244,13 +223,19 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
const user = userEvent.setup();
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
- const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
+ const scopes = [
+ 'trips:read',
+ 'trips:write',
+ 'places:read',
+ 'places:write',
+ 'budget:read',
+ 'budget:write',
+ 'packing:read',
+ ];
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
- sessions: [
- { id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
- ],
+ sessions: [{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }],
})
)
);
@@ -270,15 +255,24 @@ describe('AdminMcpTokensPanel', () => {
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
- { id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
+ {
+ id: 5,
+ client_name: 'Revoke Me',
+ username: 'carol',
+ scopes: ['trips:read'],
+ created_at: '2025-01-01T00:00:00Z',
+ },
],
})
),
- http.delete('/api/admin/oauth-sessions/5', () =>
- HttpResponse.json({ success: true })
- )
+ http.delete('/api/admin/oauth-sessions/5', () => HttpResponse.json({ success: true }))
+ );
+ render(
+ <>
+
+
+ >
);
- render(<>
>);
await screen.findByText('Revoke Me');
// Click the revoke (trash) button next to the session
@@ -289,7 +283,7 @@ describe('AdminMcpTokensPanel', () => {
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
// 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.hasAttribute('aria-label'));
+ 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();
@@ -302,21 +296,30 @@ describe('AdminMcpTokensPanel', () => {
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
- { id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
+ {
+ id: 6,
+ client_name: 'Error Session',
+ username: 'dave',
+ scopes: ['trips:read'],
+ created_at: '2025-01-01T00:00:00Z',
+ },
],
})
),
- http.delete('/api/admin/oauth-sessions/6', () =>
- HttpResponse.json({ error: 'forbidden' }, { status: 403 })
- )
+ http.delete('/api/admin/oauth-sessions/6', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
+ );
+ render(
+ <>
+
+
+ >
);
- render(<>
>);
await screen.findByText('Error Session');
const deleteBtn = screen.getAllByLabelText('Delete')[0];
await user.click(deleteBtn);
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
- const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
+ 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 a23a96234..97e39f529 100644
--- a/client/src/components/Admin/AdminMcpTokensPanel.tsx
+++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx
@@ -1,40 +1,41 @@
-import { useState, useEffect, type ReactNode } from 'react'
-import { adminApi } from '../../api/client'
-import { useToast } from '../shared/Toast'
-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'
+import { Bot, Key, KeyRound, Loader2, Shield, Trash2, User } from 'lucide-react';
+import { useEffect, useState, type ReactNode } from 'react';
+import { adminApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit';
+import ConfirmDialog from '../shared/ConfirmDialog';
+import { fs } from '../shared/DialogShell';
+import { useToast } from '../shared/Toast';
+import { Tooltip } from '../shared/Tooltip';
interface AdminOAuthSession {
- id: number
- client_id: string
- client_name: string
- user_id: number
- username: string
- scopes: string[]
- access_token_expires_at: string
- refresh_token_expires_at: string
- created_at: string
+ id: number;
+ client_id: string;
+ client_name: string;
+ user_id: number;
+ username: string;
+ scopes: string[];
+ access_token_expires_at: string;
+ refresh_token_expires_at: string;
+ created_at: string;
}
interface AdminMcpToken {
- id: number
- name: string
- token_prefix: string
- created_at: string
- last_used_at: string | null
- user_id: number
- username: string
+ id: number;
+ name: string;
+ token_prefix: string;
+ created_at: string;
+ last_used_at: string | null;
+ user_id: number;
+ username: string;
}
-const SCOPES_PREVIEW = 6
+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'
+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 }) {
@@ -43,100 +44,114 @@ function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode;
- )
+ );
}
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}
+
+ {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)
- const [tokens, setTokens] = useState([])
- const [tokensLoading, setTokensLoading] = useState(true)
- const [expandedScopes, setExpandedScopes] = useState>(new Set())
- const [revokeConfirmId, setRevokeConfirmId] = useState(null)
- const [deleteConfirmId, setDeleteConfirmId] = useState(null)
+ const [sessions, setSessions] = useState([]);
+ const [sessionsLoading, setSessionsLoading] = useState(true);
+ const [tokens, setTokens] = useState([]);
+ const [tokensLoading, setTokensLoading] = useState(true);
+ const [expandedScopes, setExpandedScopes] = useState>(new Set());
+ const [revokeConfirmId, setRevokeConfirmId] = useState(null);
+ const [deleteConfirmId, setDeleteConfirmId] = useState(null);
const toggleScopes = (id: number) =>
- setExpandedScopes(prev => {
- const next = new Set(prev)
- next.has(id) ? next.delete(id) : next.add(id)
- return next
- })
- const toast = useToast()
- const { t, locale } = useTranslation()
+ setExpandedScopes((prev) => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ const toast = useToast();
+ const { t, locale } = useTranslation();
useEffect(() => {
- adminApi.oauthSessions()
- .then(d => setSessions(d.sessions || []))
+ adminApi
+ .oauthSessions()
+ .then((d) => setSessions(d.sessions || []))
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
- .finally(() => setSessionsLoading(false))
+ .finally(() => setSessionsLoading(false));
- adminApi.mcpTokens()
- .then(d => setTokens(d.tokens || []))
+ adminApi
+ .mcpTokens()
+ .then((d) => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
- .finally(() => setTokensLoading(false))
- }, [])
+ .finally(() => setTokensLoading(false));
+ }, []);
const handleRevoke = async (id: number) => {
try {
- await adminApi.revokeOAuthSession(id)
- setSessions(prev => prev.filter(s => s.id !== id))
- setRevokeConfirmId(null)
- toast.success(t('admin.oauthSessions.revokeSuccess'))
+ await adminApi.revokeOAuthSession(id);
+ setSessions((prev) => prev.filter((s) => s.id !== id));
+ setRevokeConfirmId(null);
+ toast.success(t('admin.oauthSessions.revokeSuccess'));
} catch {
- toast.error(t('admin.oauthSessions.revokeError'))
+ toast.error(t('admin.oauthSessions.revokeError'));
}
- }
+ };
const handleDelete = async (id: number) => {
try {
- await adminApi.deleteMcpToken(id)
- setTokens(prev => prev.filter(tk => tk.id !== id))
- setDeleteConfirmId(null)
- toast.success(t('admin.mcpTokens.deleteSuccess'))
+ await adminApi.deleteMcpToken(id);
+ setTokens((prev) => prev.filter((tk) => tk.id !== id));
+ setDeleteConfirmId(null);
+ toast.success(t('admin.mcpTokens.deleteSuccess'));
} catch {
- toast.error(t('admin.mcpTokens.deleteError'))
+ toast.error(t('admin.mcpTokens.deleteError'));
}
- }
+ };
- const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
+ const date = (iso: string) => new Date(iso).toLocaleDateString(locale);
return (
@@ -149,25 +164,38 @@ export default function AdminMcpTokensPanel() {
badge={!sessionsLoading && sessions.length > 0 ?
{sessions.length} : undefined}
>
{sessionsLoading || sessions.length === 0 ? (
-
} text={t('admin.oauthSessions.empty')} />
+
}
+ 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
+ {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}
+
+ {session.client_name}
+
- {visible.map(scope => (
- {scope}
+ {visible.map((scope) => (
+
+ {scope}
+
))}
{hidden > 0 && (
- toggleScopes(session.id)}
- className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content">
+ toggleScopes(session.id)}
+ className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content"
+ >
{expanded ? 'show less' : `+${hidden} more`}
)}
@@ -179,7 +207,7 @@ export default function AdminMcpTokensPanel() {
setRevokeConfirmId(session.id)} />
- )
+ );
})}
)}
@@ -192,15 +220,25 @@ export default function AdminMcpTokensPanel() {
badge={!tokensLoading && tokens.length > 0 ?
{tokens.length} : undefined}
>
{tokensLoading || tokens.length === 0 ? (
-
} text={t('admin.mcpTokens.empty')} />
+
}
+ text={t('admin.mcpTokens.empty')}
+ />
) : (
- {tokens.map(token => (
+ {tokens.map((token) => (
-
+
+
+
-
{token.name}
-
{token.token_prefix}...
+
+ {token.name}
+
+
+ {token.token_prefix}...
+
@@ -220,7 +258,9 @@ export default function AdminMcpTokensPanel() {
setRevokeConfirmId(null)}
- onConfirm={() => { if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId) }}
+ onConfirm={() => {
+ if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId);
+ }}
title={t('admin.oauthSessions.revokeTitle')}
message={t('admin.oauthSessions.revokeMessage')}
confirmLabel={t('common.delete')}
@@ -231,12 +271,14 @@ export default function AdminMcpTokensPanel() {
setDeleteConfirmId(null)}
- onConfirm={() => { if (deleteConfirmId !== null) void handleDelete(deleteConfirmId) }}
+ 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 472f6a7da..f0ba41a6a 100644
--- a/client/src/components/Admin/AdminPluginsPanel.test.tsx
+++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx
@@ -1,11 +1,11 @@
-import { http, HttpResponse } from 'msw'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import userEvent from '@testing-library/user-event'
-import { server } from '../../../tests/helpers/msw/server'
-import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render'
-import { resetAllStores } from '../../../tests/helpers/store'
-import { ToastContainer } from '../shared/Toast'
-import AdminPluginsPanel from './AdminPluginsPanel'
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { server } from '../../../tests/helpers/msw/server';
+import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
+import { resetAllStores } from '../../../tests/helpers/store';
+import { ToastContainer } from '../shared/Toast';
+import AdminPluginsPanel from './AdminPluginsPanel';
/**
* The "allowed hosts" chip. A plugin that talks to a SELF-HOSTED service (a Gotify) can't
@@ -15,9 +15,17 @@ import AdminPluginsPanel from './AdminPluginsPanel'
*/
function plugin(over: Record
= {}) {
return {
- id: 'trek-gotify', name: 'Gotify', description: 'Push notifications', type: 'integration',
- icon: 'Bell', version: '1.0.0', status: 'active', enabled: 1,
- last_error: null, reviewed_at: null, source_repo: null,
+ id: 'trek-gotify',
+ name: 'Gotify',
+ description: 'Push notifications',
+ type: 'integration',
+ icon: 'Bell',
+ version: '1.0.0',
+ status: 'active',
+ enabled: 1,
+ last_error: null,
+ reviewed_at: null,
+ source_repo: null,
permissions: JSON.stringify(['hook:notification-channel', 'http:outbound:gotify.net']),
capabilities: '{}',
operatorEgress: true,
@@ -25,59 +33,60 @@ function plugin(over: Record = {}) {
dependencyStatus: 'ok',
dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [] },
...over,
- }
+ };
}
function mockList(p: Record) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ plugins: [] })),
- )
+ http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ plugins: [] }))
+ );
}
-beforeEach(() => resetAllStores())
+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)
+ 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 }))
- render( )
+ mockList(plugin({ egressHostCount: 0 }));
+ render( );
// The plugin can't reach anything yet — the card must say so, not stay silent.
- expect(await screen.findByRole('button', { name: /add allowed host/i })).toBeInTheDocument()
- })
+ expect(await screen.findByRole('button', { name: /add allowed host/i })).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-EGRESS-002: shows the count once hosts exist', async () => {
- mockList(plugin({ egressHostCount: 2 }))
- render( )
- expect(await screen.findByRole('button', { name: /2 allowed host/i })).toBeInTheDocument()
- expect(screen.queryByRole('button', { name: /add allowed host/i })).not.toBeInTheDocument()
- })
+ mockList(plugin({ egressHostCount: 2 }));
+ render( );
+ expect(await screen.findByRole('button', { name: /2 allowed host/i })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /add allowed host/i })).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-EGRESS-003: a plugin that never declared operatorEgress gets NO chip', async () => {
- mockList(plugin({ operatorEgress: false }))
- render( )
- await screen.findByText('Gotify')
+ mockList(plugin({ operatorEgress: false }));
+ render( );
+ await screen.findByText('Gotify');
// An admin must never be invited to widen egress for a plugin that didn't ask for it.
- expect(screen.queryByRole('button', { name: /allowed host/i })).not.toBeInTheDocument()
- })
+ expect(screen.queryByRole('button', { name: /allowed host/i })).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-EGRESS-004: clicking the chip opens the allowed-hosts dialog', async () => {
- mockList(plugin({ egressHostCount: 1 }))
+ mockList(plugin({ egressHostCount: 1 }));
server.use(
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
- HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })),
- )
- render( )
+ HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })
+ )
+ );
+ render( );
- fireEvent.click(await screen.findByRole('button', { name: /1 allowed host/i }))
- await waitFor(() => expect(screen.getByText('gotify.mydomain.com')).toBeInTheDocument())
- })
-})
+ fireEvent.click(await screen.findByRole('button', { name: /1 allowed host/i }));
+ await waitFor(() => expect(screen.getByText('gotify.mydomain.com')).toBeInTheDocument());
+ });
+});
/**
* The Discover (pre-install) modal. Its "Connects to" list is what a reviewer reads to
@@ -89,44 +98,71 @@ function mockDetail(manifest: Record | null) {
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
// pluginBrowse returns the ARRAY itself, not { plugins: [...] }.
http.get('*/api/admin/plugins/registry', () =>
- HttpResponse.json([{ id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify', type: 'integration', tags: [] }])),
+ HttpResponse.json([
+ {
+ id: 'trek-gotify',
+ name: 'Gotify',
+ author: 'jubnl',
+ description: 'Push',
+ repo: 'jubnl/trek-gotify',
+ type: 'integration',
+ tags: [],
+ },
+ ])
+ ),
http.get('*/api/admin/plugins/registry/trek-gotify', () =>
HttpResponse.json({
- id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify',
- type: 'integration', tags: [], size: 1024, publishedAt: null, latest: '1.0.0', manifest,
- })),
- )
+ id: 'trek-gotify',
+ name: 'Gotify',
+ author: 'jubnl',
+ description: 'Push',
+ repo: 'jubnl/trek-gotify',
+ type: 'integration',
+ tags: [],
+ size: 1024,
+ publishedAt: null,
+ latest: '1.0.0',
+ manifest,
+ })
+ )
+ );
}
describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
- const base = { permissions: ['hook:notification-channel', 'http:outbound:gotify.net'], egress: ['gotify.net'], settings: [], license: 'MIT', icon: null }
+ const base = {
+ permissions: ['hook:notification-channel', 'http:outbound:gotify.net'],
+ egress: ['gotify.net'],
+ settings: [],
+ license: 'MIT',
+ icon: null,
+ };
/** The panel opens on Installed — switch to Discover, then open the plugin's card. */
async function openDetail() {
- render( )
- fireEvent.click(await screen.findByRole('button', { name: /discover/i }))
- fireEvent.click(await screen.findByText('Gotify'))
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: /discover/i }));
+ fireEvent.click(await screen.findByText('Gotify'));
}
it('FE-COMP-PLUGINS-EGRESS-005: warns that the host list is not the whole story', async () => {
- mockDetail({ ...base, operatorEgress: true })
- await openDetail()
+ mockDetail({ ...base, operatorEgress: true });
+ await openDetail();
// The declared host is still listed…
- expect(await screen.findByText('gotify.net')).toBeInTheDocument()
+ expect(await screen.findByText('gotify.net')).toBeInTheDocument();
// …alongside the pill saying an admin adds more.
- expect(screen.getByText(/hosts you add/i)).toBeInTheDocument()
- })
+ expect(screen.getByText(/hosts you add/i)).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-EGRESS-006: an ordinary plugin gets NO such pill', async () => {
- mockDetail({ ...base, operatorEgress: false })
- await openDetail()
+ mockDetail({ ...base, operatorEgress: false });
+ await openDetail();
- expect(await screen.findByText('gotify.net')).toBeInTheDocument()
+ expect(await screen.findByText('gotify.net')).toBeInTheDocument();
// Its egress list IS the whole story — claiming otherwise would be a lie.
- expect(screen.queryByText(/hosts you add/i)).not.toBeInTheDocument()
- })
-})
+ expect(screen.queryByText(/hosts you add/i)).not.toBeInTheDocument();
+ });
+});
/**
* #1523. The row's ⋯ menu used to be an in-flow `absolute` div, and PageSidebar — the
@@ -137,62 +173,81 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
// operatorEgress so the menu shows every action, Allowed hosts included (it is
// gated on the declaration, like the row chip).
- const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: true })
- const realRect = HTMLButtonElement.prototype.getBoundingClientRect
- afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect })
+ const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: true });
+ const realRect = HTMLButtonElement.prototype.getBoundingClientRect;
+ afterEach(() => {
+ HTMLButtonElement.prototype.getBoundingClientRect = realRect;
+ });
/** Put the ⋯ button wherever we want in an 800px-tall viewport. */
function stubTriggerAt(top: number) {
- window.innerHeight = 800
- window.innerWidth = 1200
+ window.innerHeight = 800;
+ window.innerWidth = 1200;
HTMLButtonElement.prototype.getBoundingClientRect = function () {
- return { top, bottom: top + 34, left: 1100, right: 1134, width: 34, height: 34, x: 1100, y: top, toJSON: () => ({}) } as DOMRect
- }
+ return {
+ top,
+ bottom: top + 34,
+ left: 1100,
+ right: 1134,
+ width: 34,
+ height: 34,
+ x: 1100,
+ y: top,
+ toJSON: () => ({}),
+ } as DOMRect;
+ };
}
async function openRowMenu() {
- mockList(withRepo)
- const { container } = render( )
- fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
- return { container, menu: screen.getByTestId('plugin-row-menu-trek-gotify') }
+ mockList(withRepo);
+ const { container } = render( );
+ fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
+ return { container, menu: screen.getByTestId('plugin-row-menu-trek-gotify') };
}
it('FE-COMP-PLUGINS-MENU-001: renders every action, including Delete', async () => {
- stubTriggerAt(100)
- await openRowMenu()
+ stubTriggerAt(100);
+ await openRowMenu();
- for (const label of [/restart/i, /error log/i, /allowed hosts/i, /source repository/i, /report an issue/i, /delete/i]) {
- expect(screen.getByText(label)).toBeInTheDocument()
+ for (const label of [
+ /restart/i,
+ /error log/i,
+ /allowed hosts/i,
+ /source repository/i,
+ /report an issue/i,
+ /delete/i,
+ ]) {
+ expect(screen.getByText(label)).toBeInTheDocument();
}
- })
+ });
it('FE-COMP-PLUGINS-MENU-002: is portaled out of the panel, so no overflow ancestor can clip it', async () => {
- stubTriggerAt(100)
- const { container, menu } = await openRowMenu()
+ stubTriggerAt(100);
+ const { container, menu } = await openRowMenu();
// THE regression guard: living inside the panel is exactly what got it clipped.
- expect(container.contains(menu)).toBe(false)
- expect(menu.parentElement).toBe(document.body)
- expect(menu.style.position).toBe('fixed')
- })
+ expect(container.contains(menu)).toBe(false);
+ expect(menu.parentElement).toBe(document.body);
+ expect(menu.style.position).toBe('fixed');
+ });
it('FE-COMP-PLUGINS-MENU-003: hangs below the ⋯ when there is room', async () => {
- stubTriggerAt(100)
- const { menu } = await openRowMenu()
+ stubTriggerAt(100);
+ const { menu } = await openRowMenu();
- expect(menu.style.top).toBe('138px') // trigger bottom (134) + 4
- expect(menu.style.bottom).toBe('')
- expect(menu.style.right).toBe('66px') // viewport (1200) - trigger right (1134)
- })
+ expect(menu.style.top).toBe('138px'); // trigger bottom (134) + 4
+ expect(menu.style.bottom).toBe('');
+ expect(menu.style.right).toBe('66px'); // viewport (1200) - trigger right (1134)
+ });
it('FE-COMP-PLUGINS-MENU-004: flips upward for a row near the bottom — the #1523 case', async () => {
- stubTriggerAt(700) // 66px of room below: the six-item menu would run off-screen
- const { menu } = await openRowMenu()
+ stubTriggerAt(700); // 66px of room below: the six-item menu would run off-screen
+ const { menu } = await openRowMenu();
- expect(menu.style.bottom).toBe('104px') // viewport (800) - trigger top (700) + 4
- expect(menu.style.top).toBe('')
- })
-})
+ expect(menu.style.bottom).toBe('104px'); // viewport (800) - trigger top (700) + 4
+ expect(menu.style.top).toBe('');
+ });
+});
/**
* Signature status (#plugins). TREK has always verified author signatures and TOFU-pinned
@@ -206,160 +261,172 @@ describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
*/
function registryEntry(over: Record = {}) {
return {
- id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
- type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
- screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ id: 'trek-gotify',
+ name: 'Gotify',
+ author: 'Acme',
+ description: 'Push',
+ repo: 'acme/gotify',
+ type: 'integration',
+ latest: '2.0.0',
+ minTrekVersion: null,
+ reviewedAt: null,
+ screenshotUrl: null,
+ signed: true,
+ authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
// The real browse response always carries latestCompatible (server-side hostCompat);
// mirror `latest` by default, exactly like a fully-compatible entry.
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
...over,
- }
+ };
}
function mockPanel(p: Record, entry: Record | null = registryEntry()) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
- )
+ http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : []))
+ );
}
describe('AdminPluginsPanel — signature badges', () => {
it('FE-COMP-PLUGINS-SIG-001: a registry plugin with a pinned key reads as Signed', async () => {
- mockPanel(plugin({ source_repo: 'acme/gotify', signed: true, keyFingerprint: 'AAAAAAAA…BBBBBBBB' }))
- render( )
- expect(await screen.findByText('Signed')).toBeInTheDocument()
- expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
- })
+ mockPanel(plugin({ source_repo: 'acme/gotify', signed: true, keyFingerprint: 'AAAAAAAA…BBBBBBBB' }));
+ render( );
+ expect(await screen.findByText('Signed')).toBeInTheDocument();
+ expect(screen.queryByText('Unsigned')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-SIG-002: a registry plugin with no key reads as Unsigned', async () => {
- mockPanel(plugin({ source_repo: 'acme/gotify', signed: false, keyFingerprint: null }))
- render( )
- expect(await screen.findByText('Unsigned')).toBeInTheDocument()
- })
+ mockPanel(plugin({ source_repo: 'acme/gotify', signed: false, keyFingerprint: null }));
+ render( );
+ expect(await screen.findByText('Unsigned')).toBeInTheDocument();
+ });
// The precedence rule. `signed` derives from the pinned key, sideloaded from source_repo
// — so they are NOT mutually exclusive in the data, and a sideloaded plugin genuinely has
// no key. Rendering "Unsigned" NEXT TO "Sideloaded" would double up on a plugin whose
// badge already says something strictly stronger, diluting the amber into wallpaper.
it('FE-COMP-PLUGINS-SIG-003: a sideloaded plugin shows Sideloaded and NO trust badge', async () => {
- mockPanel(plugin({ source_repo: 'local:upload', signed: false }))
- render( )
- expect(await screen.findByText('Sideloaded')).toBeInTheDocument()
- expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
- expect(screen.queryByText('Signed')).not.toBeInTheDocument()
- })
+ mockPanel(plugin({ source_repo: 'local:upload', signed: false }));
+ render( );
+ expect(await screen.findByText('Sideloaded')).toBeInTheDocument();
+ expect(screen.queryByText('Unsigned')).not.toBeInTheDocument();
+ expect(screen.queryByText('Signed')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-SIG-004: a dev-linked plugin shows Dev-Link and NO trust badge', async () => {
- mockPanel(plugin({ source_repo: 'local:link', signed: false }))
- render( )
- expect(await screen.findByText('Dev-Link')).toBeInTheDocument()
- expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
- })
-})
+ mockPanel(plugin({ source_repo: 'local:link', signed: false }));
+ render( );
+ expect(await screen.findByText('Dev-Link')).toBeInTheDocument();
+ expect(screen.queryByText('Unsigned')).not.toBeInTheDocument();
+ });
+});
describe('AdminPluginsPanel — a refused update', () => {
const blocked = (code: string) =>
plugin({
- source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa',
+ source_repo: 'acme/gotify',
+ signed: true,
+ keyFingerprint: 'OLDKEYaa…aaaaaaaa',
updateBlock: { code, detail: 'the signing key changed', version: '2.0.0' },
- })
+ });
it('FE-COMP-PLUGINS-SIG-005: the row keeps showing WHY, instead of the reason dying with a toast', async () => {
- mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
- render( )
- expect(await screen.findByText(/update blocked/i)).toBeInTheDocument()
- })
+ mockPanel(blocked('SIGNATURE_KEY_CHANGED'));
+ render( );
+ expect(await screen.findByText(/update blocked/i)).toBeInTheDocument();
+ });
// The block describes the version that was REFUSED. Once the registry offers a newer one,
// it describes an artifact nobody is being offered anymore — so it reads as stale and the
// admin can simply re-attempt.
it('FE-COMP-PLUGINS-SIG-006: the block goes quiet once a NEWER version is on offer', async () => {
- mockPanel(blocked('SIGNATURE_KEY_CHANGED'), registryEntry({ latest: '3.0.0' }))
- render( )
- await screen.findByText('Gotify')
- await waitFor(() => expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument())
- })
+ mockPanel(blocked('SIGNATURE_KEY_CHANGED'), registryEntry({ latest: '3.0.0' }));
+ render( );
+ await screen.findByText('Gotify');
+ await waitFor(() => expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument());
+ });
it('FE-COMP-PLUGINS-SIG-007: Review opens the re-trust dialog for a ROTATED key', async () => {
- mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
- render( )
- fireEvent.click(await screen.findByRole('button', { name: /review/i }))
+ mockPanel(blocked('SIGNATURE_KEY_CHANGED'));
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: /review/i }));
// Both fingerprints, so the admin can compare them against what the author tells them.
- expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
- expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
+ expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument();
+ expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument();
// The dialog body scrolls; the decision buttons sit outside it, so they never leave the viewport (#2159).
- const body = screen.getByText(/key it is offering now/i).closest('.overflow-y-auto')
- expect(body).not.toBeNull()
- expect(body).not.toContainElement(screen.getByRole('button', { name: /trust the new key/i }))
- })
+ const body = screen.getByText(/key it is offering now/i).closest('.overflow-y-auto');
+ expect(body).not.toBeNull();
+ expect(body).not.toContainElement(screen.getByRole('button', { name: /trust the new key/i }));
+ });
// D2, at the UI. An invalid signature means the bytes are not what the author signed.
// There is no override — not a disabled button, not one behind a confirm. The ABSENCE of
// an escape hatch is the feature. (The server refuses it too; this is belt and braces.)
it('FE-COMP-PLUGINS-SIG-008: an INVALID signature offers NO re-trust affordance at all', async () => {
- mockPanel(blocked('SIGNATURE_INVALID'))
- render( )
- fireEvent.click(await screen.findByRole('button', { name: /review/i }))
+ mockPanel(blocked('SIGNATURE_INVALID'));
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: /review/i }));
- await screen.findByText(/do not match the author's signature/i)
- expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
+ await screen.findByText(/do not match the author's signature/i);
+ expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument();
// ...and it does not even show the key comparison, which would imply a choice exists.
- expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument()
- })
+ expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-SIG-009: an unsigned-downgrade refusal offers no override either', async () => {
- mockPanel(blocked('SIGNATURE_MISSING'))
- render( )
- fireEvent.click(await screen.findByRole('button', { name: /review/i }))
+ mockPanel(blocked('SIGNATURE_MISSING'));
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: /review/i }));
- await screen.findByText(/ships no signature/i)
- expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
- })
+ await screen.findByText(/ships no signature/i);
+ expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-SIG-010: confirming a re-trust re-pins AND updates in ONE call', async () => {
- let body: unknown = null
- mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
+ let body: unknown = null;
+ mockPanel(blocked('SIGNATURE_KEY_CHANGED'));
server.use(
http.post('*/api/admin/plugins/trek-gotify/retrust', async ({ request }) => {
- body = await request.json()
- return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
- }),
- )
- render( )
- fireEvent.click(await screen.findByRole('button', { name: /review/i }))
- fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }))
+ body = await request.json();
+ return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
+ })
+ );
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: /review/i }));
+ fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }));
// The FULL key goes back, not the fingerprint: the server's equality check is exact, so
// it can refuse if the entry was re-keyed again since this dialog rendered.
- await waitFor(() =>
- expect(body).toEqual({ version: '2.0.0', publicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }),
- )
+ await waitFor(() => expect(body).toEqual({ version: '2.0.0', publicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }));
// No follow-up /update: a re-pin that waited for a second call would leave the plugin
// pinned to a key no install had ever verified against if that call never came.
- })
-})
+ });
+});
describe('AdminPluginsPanel — update consent', () => {
it('FE-COMP-PLUGINS-SIG-011: says an unsigned update is untied to its author, and still activates in one click', async () => {
- let activated = false
- mockPanel(plugin({ source_repo: 'acme/gotify', signed: false }), registryEntry({ signed: false }))
+ let activated = false;
+ mockPanel(plugin({ source_repo: 'acme/gotify', signed: false }), registryEntry({ signed: false }));
server.use(
http.post('*/api/admin/plugins/trek-gotify/update', () =>
- HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] }),
+ HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] })
),
- http.post('*/api/admin/plugins/trek-gotify/activate', () => { activated = true; return HttpResponse.json({ status: 'active' }) }),
- )
- render( )
- await screen.findByText('Gotify')
- fireEvent.click(await screen.findByRole('button', { name: /update to|2\.0\.0/i }))
+ http.post('*/api/admin/plugins/trek-gotify/activate', () => {
+ activated = true;
+ return HttpResponse.json({ status: 'active' });
+ })
+ );
+ render( );
+ await screen.findByText('Gotify');
+ fireEvent.click(await screen.findByRole('button', { name: /update to|2\.0\.0/i }));
// Informs — it does not block. No checkbox, no second click.
- expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: /approve & turn on/i }))
- await waitFor(() => expect(activated).toBe(true))
- })
+ expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /approve & turn on/i }));
+ await waitFor(() => expect(activated).toBe(true));
+ });
// The warning used to be read ONLY off the registry entry, so an unreachable registry left
// it undefined and the pill silently vanished — at the exact moment the admin was widening
@@ -373,38 +440,61 @@ describe('AdminPluginsPanel — update consent', () => {
server.use(
http.get('*/api/admin/plugins', () =>
HttpResponse.json({
- enabled: true, devLink: false,
- plugins: [plugin({ source_repo: 'acme/gotify', signed: false, enabled: 0, status: 'inactive', operatorEgress: false })],
- })),
+ enabled: true,
+ devLink: false,
+ plugins: [
+ plugin({
+ source_repo: 'acme/gotify',
+ signed: false,
+ enabled: 0,
+ status: 'inactive',
+ operatorEgress: false,
+ }),
+ ],
+ })
+ ),
// The registry is down: `regById` stays empty, so the entry's `signed` is unknowable.
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'registry unreachable' }, { status: 500 })),
+ http.get('*/api/admin/plugins/registry', () =>
+ HttpResponse.json({ error: 'registry unreachable' }, { status: 500 })
+ ),
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
- HttpResponse.json({ error: 'consent required', code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 })),
- )
- render( )
- fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }))
+ HttpResponse.json(
+ { error: 'consent required', code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] },
+ { status: 409 }
+ )
+ )
+ );
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }));
// Falls back to the installed row's `signed: false` rather than going quiet.
- expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument()
- })
+ expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-054: an update asking for the POI category grant spells out what it sends (#1781)', async () => {
- mockPanel(plugin({ source_repo: 'acme/gotify', signed: true }), registryEntry())
+ mockPanel(plugin({ source_repo: 'acme/gotify', signed: true }), registryEntry());
server.use(
http.post('*/api/admin/plugins/trek-gotify/update', () =>
- HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['hook:poi-category-provider'], newEgress: [] }),
- ),
- )
- render( )
- await screen.findByText('Gotify')
- fireEvent.click(await screen.findByRole('button', { name: /update to|2\.0\.0/i }))
+ HttpResponse.json({
+ version: '2.0.0',
+ activated: false,
+ newPermissions: ['hook:poi-category-provider'],
+ newEgress: [],
+ })
+ )
+ );
+ render( );
+ await screen.findByText('Gotify');
+ fireEvent.click(await screen.findByRole('button', { name: /update to|2\.0\.0/i }));
- expect(await screen.findByText(
- 'Add its own place categories to Explore places on the map; picking one sends the plugin the map area you are viewing',
- )).toBeInTheDocument()
- expect(screen.queryByText('hook:poi-category-provider')).not.toBeInTheDocument()
- })
-})
+ expect(
+ await screen.findByText(
+ 'Add its own place categories to Explore places on the map; picking one sends the plugin the map area you are viewing'
+ )
+ ).toBeInTheDocument();
+ expect(screen.queryByText('hook:poi-category-provider')).not.toBeInTheDocument();
+ });
+});
/**
* A signature refusal must reach the dialog even when the plugin has NO installed row —
@@ -422,59 +512,80 @@ describe('AdminPluginsPanel — a refusal with no installed row', () => {
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry()])),
http.post('*/api/admin/plugins/install', () =>
- HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
- )
- render( )
- fireEvent.click(await screen.findByRole('button', { name: /discover/i }))
- fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
+ HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })
+ )
+ );
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: /discover/i }));
+ fireEvent.click(await screen.findByRole('button', { name: /^install$/i }));
// The dialog, named after the plugin — which it can only know from the REGISTRY entry,
// there being no installed row to read a name off.
- expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument()
- await screen.findByText(/do not match the author's signature/i)
+ expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument();
+ await screen.findByText(/do not match the author's signature/i);
// Non-overridable, and no key comparison — showing one would imply a choice exists.
- expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
- expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument()
- })
+ expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument();
+ expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-SIG-014: a refusal while downloading a DEPENDENCY opens the dialog too', async () => {
- const parent = plugin({ id: 'trek-parent', name: 'Parent', source_repo: 'acme/parent', enabled: 0, status: 'inactive', operatorEgress: false })
+ const parent = plugin({
+ id: 'trek-parent',
+ name: 'Parent',
+ source_repo: 'acme/parent',
+ enabled: 0,
+ status: 'inactive',
+ operatorEgress: false,
+ });
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [parent] })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry()])),
// Turning it on reveals the missing dependency…
http.post('*/api/admin/plugins/trek-parent/activate', () =>
- HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-gotify', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
+ HttpResponse.json(
+ {
+ error: 'missing dependency',
+ code: 'DEPENDENCY_MISSING',
+ missing: [{ id: 'trek-gotify', version: '^1.0.0' }],
+ versionMismatch: [],
+ },
+ { status: 409 }
+ )
+ ),
// …and downloading it is refused on its signature.
http.post('*/api/admin/plugins/install', () =>
- HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
- )
- render( )
- fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }))
- fireEvent.click(await screen.findByRole('button', { name: /download/i }))
+ HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })
+ )
+ );
+ render( );
+ fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }));
+ fireEvent.click(await screen.findByRole('button', { name: /download/i }));
// Named after the DEPENDENCY, not the parent — it is the dependency's author whose
// signature did not verify, and saying "Parent" here would point the admin at the wrong
// plugin entirely.
- expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument()
- expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
- })
-})
+ expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument();
+ });
+});
describe('AdminPluginsPanel — a block never outlives the registry relationship', () => {
// The server clears the block on sideload/dev-link. This is the belt: even if a stale
// block somehow reached the client, a plugin whose code the admin supplied by hand must
// never claim an update was blocked over an author signing key.
it('FE-COMP-PLUGINS-SIG-012: a sideloaded plugin never shows an update block', async () => {
- mockPanel(plugin({
- source_repo: 'local:upload', signed: false,
- updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
- }))
- render( )
- await screen.findByText('Sideloaded')
- expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument()
- })
-})
+ mockPanel(
+ plugin({
+ source_repo: 'local:upload',
+ signed: false,
+ updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
+ })
+ );
+ render( );
+ await screen.findByText('Sideloaded');
+ expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument();
+ });
+});
/**
* TREK-version compatibility. The SERVER owns the semver — a second implementation in the
@@ -484,38 +595,50 @@ describe('AdminPluginsPanel — a block never outlives the registry relationship
describe('AdminPluginsPanel — TREK-version compatibility', () => {
/** Discover cards for a plugin that is NOT installed — an installed one just reads "Installed". */
async function openDiscover(entry: Record) {
- mockPanel(plugin({ id: 'something-else' }), registryEntry(entry))
- render( )
- fireEvent.click(await screen.findByText('Discover'))
+ mockPanel(plugin({ id: 'something-else' }), registryEntry(entry));
+ render( );
+ fireEvent.click(await screen.findByText('Discover'));
}
it('blocks Install when no published version runs on this TREK, and says why', async () => {
- await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
- const btn = await screen.findByRole('button', { name: /^incompatible$/i })
- expect(btn).toBeDisabled()
- })
+ await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null });
+ const btn = await screen.findByRole('button', { name: /^incompatible$/i });
+ expect(btn).toBeDisabled();
+ });
it('offers the newest version that DOES run here rather than a dead button', async () => {
- await openDiscover({ latest: '2.0.0', trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' })
- const btn = await screen.findByRole('button', { name: /^install 1\.5\.0$/i })
- expect(btn).toBeEnabled()
- })
+ await openDiscover({
+ latest: '2.0.0',
+ trek: '>=3.4.0',
+ hostVersion: '3.3.0',
+ compatible: false,
+ latestCompatible: '1.5.0',
+ });
+ const btn = await screen.findByRole('button', { name: /^install 1\.5\.0$/i });
+ expect(btn).toBeEnabled();
+ });
it('installs normally when the latest version fits', async () => {
- await openDiscover({ trek: '>=3.2.0 <4.0.0', hostVersion: '3.3.0', compatible: true, latestCompatible: '2.0.0' })
- expect(await screen.findByRole('button', { name: /^install$/i })).toBeEnabled()
- })
+ await openDiscover({ trek: '>=3.2.0 <4.0.0', hostVersion: '3.3.0', compatible: true, latestCompatible: '2.0.0' });
+ expect(await screen.findByRole('button', { name: /^install$/i })).toBeEnabled();
+ });
it('an installed plugin the server has outgrown shows the blocker on its card', async () => {
// Same amber chip machinery as a disabled addon / missing dependency — the admin sees
// one "here is why this cannot turn on" surface, not a new concept per blocker.
- mockPanel(plugin({
- dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0', enabled: 0, status: 'inactive',
- }))
- render( )
- expect(await screen.findByText(/needs trek >=3\.2\.0 <4\.0\.0/i)).toBeInTheDocument()
- })
-})
+ mockPanel(
+ plugin({
+ dependencyStatus: 'hostIncompatible',
+ trekRange: '>=3.2.0 <4.0.0',
+ hostVersion: '4.0.0',
+ enabled: 0,
+ status: 'inactive',
+ })
+ );
+ render( );
+ expect(await screen.findByText(/needs trek >=3\.2\.0 <4\.0\.0/i)).toBeInTheDocument();
+ });
+});
/**
* The rest of the panel: load states, the toolbar, sideloading, the row actions behind the
@@ -523,634 +646,758 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
*/
function panelWith(
plugins: Record[],
- opts: { enabled?: boolean; devLink?: boolean; registry?: Record[] } = {},
+ opts: { enabled?: boolean; devLink?: boolean; registry?: Record[] } = {}
) {
server.use(
- http.get('*/api/admin/plugins', () => HttpResponse.json({
- enabled: opts.enabled ?? true, devLink: opts.devLink ?? false, plugins,
- })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json(opts.registry ?? [])),
- )
+ http.get('*/api/admin/plugins', () =>
+ HttpResponse.json({
+ enabled: opts.enabled ?? true,
+ devLink: opts.devLink ?? false,
+ plugins,
+ })
+ ),
+ http.get('*/api/admin/plugins/registry', () => HttpResponse.json(opts.registry ?? []))
+ );
}
function withToast() {
- return render(<> >)
+ return render(
+ <>
+
+
+ >
+ );
}
/** The Discover tab is a role="tab"; the empty state offers a plain button with the same label. */
async function clickDiscover() {
- const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
- fireEvent.click(tabs[0])
+ const tabs = await screen.findAllByRole('tab', { name: /^discover/i });
+ fireEvent.click(tabs[0]);
}
/** The panel root, which owns the drag-and-drop handlers. */
function panelRoot(container: HTMLElement): HTMLElement {
- return (container.querySelector('input[type="file"]') as HTMLElement).parentElement as HTMLElement
+ return (container.querySelector('input[type="file"]') as HTMLElement).parentElement as HTMLElement;
}
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
async function pickFilter(trigger: RegExp, option: RegExp) {
- fireEvent.click(await screen.findByTitle(trigger))
- fireEvent.click(await screen.findByRole('button', { name: option }))
+ fireEvent.click(await screen.findByTitle(trigger));
+ fireEvent.click(await screen.findByRole('button', { name: option }));
}
/** The enable toggle of a named installed row. */
function rowToggle(name: string): HTMLElement {
- const row = screen.getByText(name).closest('.group') as HTMLElement
- return within(row).getByRole('button', { name: 'Enable plugin' })
+ const row = screen.getByText(name).closest('.group') as HTMLElement;
+ return within(row).getByRole('button', { name: 'Enable plugin' });
}
describe('AdminPluginsPanel — load states and toolbar', () => {
it('FE-COMP-PLUGINS-PANEL-001: a failing plugin list shows the load error instead of an empty list', async () => {
- server.use(http.get('*/api/admin/plugins', () => HttpResponse.error()))
- render( )
+ server.use(http.get('*/api/admin/plugins', () => HttpResponse.error()));
+ render( );
- expect(await screen.findByText('Could not load plugins.')).toBeInTheDocument()
- expect(screen.queryByPlaceholderText('Search plugins…')).not.toBeInTheDocument()
- })
+ expect(await screen.findByText('Could not load plugins.')).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('Search plugins…')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-002: a disabled runtime replaces the whole body with the notice', async () => {
- panelWith([plugin()], { enabled: false })
- render( )
+ panelWith([plugin()], { enabled: false });
+ render( );
- expect(await screen.findByText('Plugins are disabled')).toBeInTheDocument()
- expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
- expect(screen.queryByText('Runtime on')).not.toBeInTheDocument()
- })
+ expect(await screen.findByText('Plugins are disabled')).toBeInTheDocument();
+ expect(screen.queryByText('Gotify')).not.toBeInTheDocument();
+ expect(screen.queryByText('Runtime on')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-003: with nothing installed the empty state jumps to Discover', async () => {
- panelWith([])
- render( )
+ panelWith([]);
+ render( );
- await screen.findByText('No plugins installed yet.')
- expect(screen.getByText('Runtime on')).toBeInTheDocument()
- fireEvent.click(screen.getAllByRole('button', { name: /^discover/i }).pop()!)
+ await screen.findByText('No plugins installed yet.');
+ expect(screen.getByText('Runtime on')).toBeInTheDocument();
+ fireEvent.click(screen.getAllByRole('button', { name: /^discover/i }).pop()!);
- expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument()
- })
+ expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-004: the search box filters the installed list and says when nothing matches', async () => {
- panelWith([plugin({ id: 'a', name: 'Gotify' }), plugin({ id: 'b', name: 'Ntfy', description: 'Push too' })])
- render( )
- await screen.findByText('Gotify')
+ panelWith([plugin({ id: 'a', name: 'Gotify' }), plugin({ id: 'b', name: 'Ntfy', description: 'Push too' })]);
+ render( );
+ await screen.findByText('Gotify');
- fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'ntfy' } })
- expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
- expect(screen.getByText('Ntfy')).toBeInTheDocument()
+ fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'ntfy' } });
+ expect(screen.queryByText('Gotify')).not.toBeInTheDocument();
+ expect(screen.getByText('Ntfy')).toBeInTheDocument();
- fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'zzz' } })
- expect(screen.getByText('No installed plugins match your search.')).toBeInTheDocument()
- })
+ fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'zzz' } });
+ expect(screen.getByText('No installed plugins match your search.')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-005: the type filter narrows the list and marks itself active', async () => {
panelWith([
plugin({ id: 'a', name: 'Gotify', type: 'integration' }),
plugin({ id: 'b', name: 'Countdown', type: 'widget' }),
- ])
- render( )
- await screen.findByText('Gotify')
+ ]);
+ render( );
+ await screen.findByText('Gotify');
- await pickFilter(/^Type: All types$/, /^Widget$/)
+ await pickFilter(/^Type: All types$/, /^Widget$/);
- expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument()
- expect(screen.getByText('Countdown')).toBeInTheDocument()
- expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
- })
+ expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument();
+ expect(screen.getByText('Countdown')).toBeInTheDocument();
+ expect(screen.queryByText('Gotify')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-006: the status filter separates off, error and update-available rows', async () => {
- panelWith([
- plugin({ id: 'trek-gotify', name: 'OnOne', enabled: 1, status: 'active', version: '1.0.0', source_repo: 'acme/gotify' }),
- plugin({ id: 'off-one', name: 'OffOne', enabled: 0, status: 'inactive' }),
- plugin({ id: 'bad-one', name: 'BadOne', enabled: 1, status: 'error', last_error: 'boom' }),
- ], { registry: [registryEntry({ latest: '2.0.0' })] })
- render( )
- await screen.findByText('OnOne')
+ panelWith(
+ [
+ plugin({
+ id: 'trek-gotify',
+ name: 'OnOne',
+ enabled: 1,
+ status: 'active',
+ version: '1.0.0',
+ source_repo: 'acme/gotify',
+ }),
+ plugin({ id: 'off-one', name: 'OffOne', enabled: 0, status: 'inactive' }),
+ plugin({ id: 'bad-one', name: 'BadOne', enabled: 1, status: 'error', last_error: 'boom' }),
+ ],
+ { registry: [registryEntry({ latest: '2.0.0' })] }
+ );
+ render( );
+ await screen.findByText('OnOne');
- await pickFilter(/^Status: All$/, /^Off$/)
- expect(await screen.findByText('OffOne')).toBeInTheDocument()
- expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
+ await pickFilter(/^Status: All$/, /^Off$/);
+ expect(await screen.findByText('OffOne')).toBeInTheDocument();
+ expect(screen.queryByText('OnOne')).not.toBeInTheDocument();
- await pickFilter(/^Status: Off$/, /^Error$/)
- expect(await screen.findByText('BadOne')).toBeInTheDocument()
- expect(screen.getByText('boom')).toBeInTheDocument()
+ await pickFilter(/^Status: Off$/, /^Error$/);
+ expect(await screen.findByText('BadOne')).toBeInTheDocument();
+ expect(screen.getByText('boom')).toBeInTheDocument();
- await pickFilter(/^Status: Error$/, /^Update available$/)
- expect(await screen.findByText('OnOne')).toBeInTheDocument()
- expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
- })
+ await pickFilter(/^Status: Error$/, /^Update available$/);
+ expect(await screen.findByText('OnOne')).toBeInTheDocument();
+ expect(screen.queryByText('BadOne')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-007: an installed-only sort key snaps back to Name on the Discover tab', async () => {
- panelWith([plugin()], { registry: [registryEntry()] })
- render( )
- await screen.findByText('Gotify')
+ panelWith([plugin()], { registry: [registryEntry()] });
+ render( );
+ await screen.findByText('Gotify');
- await pickFilter(/^Sort: Name$/, /^Updates first$/)
- expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument()
+ await pickFilter(/^Sort: Name$/, /^Updates first$/);
+ expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument();
- await clickDiscover()
- await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument())
- })
+ await clickDiscover();
+ await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument());
+ });
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
- const refreshFlags: (string | null)[] = []
- panelWith([plugin()])
+ const refreshFlags: (string | null)[] = [];
+ panelWith([plugin()]);
server.use(
http.post('*/api/admin/plugins/rescan', () => HttpResponse.json({ ok: true })),
http.get('*/api/admin/plugins/registry', ({ request }) => {
- refreshFlags.push(new URL(request.url).searchParams.get('refresh'))
- return HttpResponse.json([registryEntry({ latest: '2.0.0' })])
- }),
- )
- withToast()
- await screen.findByText('Gotify')
+ refreshFlags.push(new URL(request.url).searchParams.get('refresh'));
+ return HttpResponse.json([registryEntry({ latest: '2.0.0' })]);
+ })
+ );
+ withToast();
+ await screen.findByText('Gotify');
- fireEvent.click(screen.getAllByRole('button', { name: 'Rescan' })[0])
+ fireEvent.click(screen.getAllByRole('button', { name: 'Rescan' })[0]);
- expect(await screen.findByText('Rescanned the plugins folder')).toBeInTheDocument()
- expect(refreshFlags).toContain('1')
- })
-})
+ expect(await screen.findByText('Rescanned the plugins folder')).toBeInTheDocument();
+ expect(refreshFlags).toContain('1');
+ });
+});
describe('AdminPluginsPanel — sideloading', () => {
- const zip = () => new File(['zip'], 'plugin.zip', { type: 'application/zip' })
+ const zip = () => new File(['zip'], 'plugin.zip', { type: 'application/zip' });
it('FE-COMP-PLUGINS-PANEL-009: the toolbar button opens the hidden picker and uploads the pick', async () => {
- panelWith([plugin()])
- server.use(http.post('*/api/admin/plugins/upload', () => HttpResponse.json({ id: 'trek-new' })))
- const { container } = withToast()
- await screen.findByText('Gotify')
+ panelWith([plugin()]);
+ server.use(http.post('*/api/admin/plugins/upload', () => HttpResponse.json({ id: 'trek-new' })));
+ const { container } = withToast();
+ await screen.findByText('Gotify');
- const input = container.querySelector('input[type="file"]') as HTMLInputElement
- const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
- fireEvent.click(screen.getAllByRole('button', { name: 'Upload plugin' })[0])
- expect(clickSpy).toHaveBeenCalled()
- clickSpy.mockRestore()
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {});
+ fireEvent.click(screen.getAllByRole('button', { name: 'Upload plugin' })[0]);
+ expect(clickSpy).toHaveBeenCalled();
+ clickSpy.mockRestore();
- await userEvent.upload(input, zip())
- expect(await screen.findByText(/trek-new.*uploaded/i)).toBeInTheDocument()
- })
+ await userEvent.upload(input, zip());
+ expect(await screen.findByText(/trek-new.*uploaded/i)).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-010: a rejected upload surfaces the server message', async () => {
- panelWith([plugin()])
- server.use(http.post('*/api/admin/plugins/upload', () => HttpResponse.json({ error: 'not a plugin archive' }, { status: 400 })))
- const { container } = withToast()
- await screen.findByText('Gotify')
+ panelWith([plugin()]);
+ server.use(
+ http.post('*/api/admin/plugins/upload', () =>
+ HttpResponse.json({ error: 'not a plugin archive' }, { status: 400 })
+ )
+ );
+ const { container } = withToast();
+ await screen.findByText('Gotify');
- await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, zip())
+ await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, zip());
- expect(await screen.findByText('not a plugin archive')).toBeInTheDocument()
- })
+ expect(await screen.findByText('not a plugin archive')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-011: dragging a file over the panel offers a drop target and installs it', async () => {
- let uploaded = false
- panelWith([plugin()])
- server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'trek-dropped' }) }))
- const { container } = withToast()
- await screen.findByText('Gotify')
+ let uploaded = false;
+ panelWith([plugin()]);
+ server.use(
+ http.post('*/api/admin/plugins/upload', () => {
+ uploaded = true;
+ return HttpResponse.json({ id: 'trek-dropped' });
+ })
+ );
+ const { container } = withToast();
+ await screen.findByText('Gotify');
- const panel = panelRoot(container)
- const dataTransfer = { types: ['Files'], files: [zip()] }
- fireEvent.dragEnter(panel, { dataTransfer })
- expect(await screen.findByText('Drop a plugin .zip to install')).toBeInTheDocument()
+ const panel = panelRoot(container);
+ const dataTransfer = { types: ['Files'], files: [zip()] };
+ fireEvent.dragEnter(panel, { dataTransfer });
+ expect(await screen.findByText('Drop a plugin .zip to install')).toBeInTheDocument();
- fireEvent.dragLeave(panel, { dataTransfer })
- await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument())
+ fireEvent.dragLeave(panel, { dataTransfer });
+ await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument());
- fireEvent.dragEnter(panel, { dataTransfer })
- fireEvent.dragOver(panel, { dataTransfer })
- fireEvent.drop(panel, { dataTransfer })
+ fireEvent.dragEnter(panel, { dataTransfer });
+ fireEvent.dragOver(panel, { dataTransfer });
+ fireEvent.drop(panel, { dataTransfer });
- await waitFor(() => expect(uploaded).toBe(true))
- expect(await screen.findByText(/trek-dropped.*uploaded/i)).toBeInTheDocument()
- })
+ await waitFor(() => expect(uploaded).toBe(true));
+ expect(await screen.findByText(/trek-dropped.*uploaded/i)).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-012: a drag that carries no files is ignored', async () => {
- panelWith([plugin()])
- const { container } = render( )
- await screen.findByText('Gotify')
+ panelWith([plugin()]);
+ const { container } = render( );
+ await screen.findByText('Gotify');
- fireEvent.dragEnter(panelRoot(container), { dataTransfer: { types: ['text/plain'], files: [] } })
+ fireEvent.dragEnter(panelRoot(container), { dataTransfer: { types: ['text/plain'], files: [] } });
- expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument()
- })
+ expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-013: the dev-link form registers a local path and reports failures', async () => {
- const paths: string[] = []
- panelWith([plugin()], { devLink: true })
- server.use(http.post('*/api/admin/plugins/link', async ({ request }) => {
- const body = await request.json() as { path: string }
- paths.push(body.path)
- return paths.length === 1
- ? HttpResponse.json({ id: 'trek-local' })
- : HttpResponse.json({ error: 'no manifest there' }, { status: 400 })
- }))
- withToast()
- const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin')
+ const paths: string[] = [];
+ panelWith([plugin()], { devLink: true });
+ server.use(
+ http.post('*/api/admin/plugins/link', async ({ request }) => {
+ const body = (await request.json()) as { path: string };
+ paths.push(body.path);
+ return paths.length === 1
+ ? HttpResponse.json({ id: 'trek-local' })
+ : HttpResponse.json({ error: 'no manifest there' }, { status: 400 });
+ })
+ );
+ withToast();
+ const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin');
- expect(screen.getByRole('button', { name: /^link$/i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /^link$/i })).toBeDisabled();
- fireEvent.change(input, { target: { value: '/srv/plugins/mine' } })
- fireEvent.click(screen.getByRole('button', { name: /^link$/i }))
- expect(await screen.findByText(/linked trek-local/i)).toBeInTheDocument()
- expect(paths).toEqual(['/srv/plugins/mine'])
+ fireEvent.change(input, { target: { value: '/srv/plugins/mine' } });
+ fireEvent.click(screen.getByRole('button', { name: /^link$/i }));
+ expect(await screen.findByText(/linked trek-local/i)).toBeInTheDocument();
+ expect(paths).toEqual(['/srv/plugins/mine']);
- fireEvent.change(screen.getByPlaceholderText('/absolute/path/to/your/plugin'), { target: { value: '/nope' } })
- fireEvent.click(screen.getByRole('button', { name: /^link$/i }))
- expect(await screen.findByText('no manifest there')).toBeInTheDocument()
- })
-})
+ fireEvent.change(screen.getByPlaceholderText('/absolute/path/to/your/plugin'), { target: { value: '/nope' } });
+ fireEvent.click(screen.getByRole('button', { name: /^link$/i }));
+ expect(await screen.findByText('no manifest there')).toBeInTheDocument();
+ });
+});
describe('AdminPluginsPanel — row actions', () => {
async function openRowMenu(p: Record = plugin()) {
- panelWith([p])
- withToast()
- fireEvent.click(await screen.findByTestId(`plugin-row-menu-btn-${p.id}`))
+ panelWith([p]);
+ withToast();
+ fireEvent.click(await screen.findByTestId(`plugin-row-menu-btn-${p.id}`));
}
/** Every modal in the panel closes by clicking its backdrop. */
function closeModal() {
- clickBackdrop(document.querySelector('.trek-modal-backdrop') 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 () => {
- server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () =>
- HttpResponse.json({ errors: [{ ts: '2026-01-01T00:00:00Z', level: 'error', message: 'connect ECONNREFUSED' }] })))
- await openRowMenu()
+ server.use(
+ http.get('*/api/admin/plugins/trek-gotify/errors', () =>
+ HttpResponse.json({ errors: [{ ts: '2026-01-01T00:00:00Z', level: 'error', message: 'connect ECONNREFUSED' }] })
+ )
+ );
+ await openRowMenu();
- fireEvent.click(screen.getByText('View error log'))
- expect(await screen.findByText('connect ECONNREFUSED')).toBeInTheDocument()
- expect(screen.getByText('error')).toBeInTheDocument()
+ fireEvent.click(screen.getByText('View error log'));
+ expect(await screen.findByText('connect ECONNREFUSED')).toBeInTheDocument();
+ expect(screen.getByText('error')).toBeInTheDocument();
- closeModal()
- await waitFor(() => expect(screen.queryByText('connect ECONNREFUSED')).not.toBeInTheDocument())
- })
+ closeModal();
+ await waitFor(() => expect(screen.queryByText('connect ECONNREFUSED')).not.toBeInTheDocument());
+ });
it('FE-COMP-PLUGINS-PANEL-015: an unreachable error log still opens, empty', async () => {
- server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.error()))
- await openRowMenu()
+ server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.error()));
+ await openRowMenu();
- fireEvent.click(screen.getByText('View error log'))
+ fireEvent.click(screen.getByText('View error log'));
- expect(await screen.findByText('No errors logged.')).toBeInTheDocument()
- })
+ expect(await screen.findByText('No errors logged.')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-016: allowed hosts can be added and removed, and a rejected save says why', async () => {
- let hosts: string[] = []
- let puts = 0
+ let hosts: string[] = [];
+ let puts = 0;
server.use(
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts })),
http.put('*/api/admin/plugins/trek-gotify/egress-hosts', async ({ request }) => {
- puts += 1
- const body = await request.json() as { hosts: string[] }
- if (puts === 3) return HttpResponse.json({ error: 'not a valid host' }, { status: 400 })
- hosts = body.hosts
- return HttpResponse.json({ hosts })
- }),
- )
- await openRowMenu()
+ puts += 1;
+ const body = (await request.json()) as { hosts: string[] };
+ if (puts === 3) return HttpResponse.json({ error: 'not a valid host' }, { status: 400 });
+ hosts = body.hosts;
+ return HttpResponse.json({ hosts });
+ })
+ );
+ await openRowMenu();
- fireEvent.click(screen.getByText('Allowed hosts'))
- expect(await screen.findByText('No hosts added yet.')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /^add$/i })).toBeDisabled()
+ fireEvent.click(screen.getByText('Allowed hosts'));
+ expect(await screen.findByText('No hosts added yet.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /^add$/i })).toBeDisabled();
- fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'gotify.lan' } })
- fireEvent.click(screen.getByRole('button', { name: /^add$/i }))
- expect(await screen.findByText('gotify.lan')).toBeInTheDocument()
+ fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'gotify.lan' } });
+ fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
+ expect(await screen.findByText('gotify.lan')).toBeInTheDocument();
- fireEvent.click(screen.getAllByLabelText('Delete')[0])
- await waitFor(() => expect(screen.getByText('No hosts added yet.')).toBeInTheDocument())
+ fireEvent.click(screen.getAllByLabelText('Delete')[0]);
+ await waitFor(() => expect(screen.getByText('No hosts added yet.')).toBeInTheDocument());
- fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'bad host' } })
- fireEvent.click(screen.getByRole('button', { name: /^add$/i }))
- expect(await screen.findByText('not a valid host')).toBeInTheDocument()
- })
+ fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'bad host' } });
+ fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
+ expect(await screen.findByText('not a valid host')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-017: a plugin whose hosts are fixed in its manifest says the list is unsupported', async () => {
- server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.error()))
- await openRowMenu()
+ server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.error()));
+ await openRowMenu();
- fireEvent.click(screen.getByText('Allowed hosts'))
+ fireEvent.click(screen.getByText('Allowed hosts'));
- expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument()
- expect(screen.queryByPlaceholderText('gotify.example.com')).not.toBeInTheDocument()
- })
+ expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('gotify.example.com')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-018: Delete asks first, then uninstalls with its data', async () => {
- let body: unknown = null
- server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', async ({ request }) => {
- body = await request.json()
- return HttpResponse.json({ ok: true })
- }))
- await openRowMenu()
+ let body: unknown = null;
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/uninstall', async ({ request }) => {
+ body = await request.json();
+ return HttpResponse.json({ ok: true });
+ })
+ );
+ await openRowMenu();
- fireEvent.click(screen.getByText('Delete'))
- expect(await screen.findByText('Uninstall plugin?')).toBeInTheDocument()
+ fireEvent.click(screen.getByText('Delete'));
+ expect(await screen.findByText('Uninstall plugin?')).toBeInTheDocument();
- fireEvent.click(screen.getAllByRole('button', { name: /^delete$/i }).pop()!)
+ fireEvent.click(screen.getAllByRole('button', { name: /^delete$/i }).pop()!);
- expect(await screen.findByText('Plugin uninstalled')).toBeInTheDocument()
- expect(body).toEqual({ deleteData: true })
- })
+ expect(await screen.findByText('Plugin uninstalled')).toBeInTheDocument();
+ expect(body).toEqual({ deleteData: true });
+ });
it('FE-COMP-PLUGINS-PANEL-019: Restart cycles the plugin off and on again', async () => {
- const seen: string[] = []
+ const seen: string[] = [];
server.use(
- http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { seen.push('deactivate'); return HttpResponse.json({ ok: true }) }),
- http.post('*/api/admin/plugins/trek-gotify/activate', () => { seen.push('activate'); return HttpResponse.json({ ok: true }) }),
- )
- await openRowMenu(plugin({ enabled: 1, status: 'active' }))
+ http.post('*/api/admin/plugins/trek-gotify/deactivate', () => {
+ seen.push('deactivate');
+ return HttpResponse.json({ ok: true });
+ }),
+ http.post('*/api/admin/plugins/trek-gotify/activate', () => {
+ seen.push('activate');
+ return HttpResponse.json({ ok: true });
+ })
+ );
+ await openRowMenu(plugin({ enabled: 1, status: 'active' }));
- fireEvent.click(screen.getByText('Restart'))
+ fireEvent.click(screen.getByText('Restart'));
- expect(await screen.findByText('Plugin restarted')).toBeInTheDocument()
- expect(seen).toEqual(['deactivate', 'activate'])
- })
+ expect(await screen.findByText('Plugin restarted')).toBeInTheDocument();
+ expect(seen).toEqual(['deactivate', 'activate']);
+ });
it('FE-COMP-PLUGINS-PANEL-020: a disabled plugin offers no Restart', async () => {
- await openRowMenu(plugin({ enabled: 0, status: 'inactive' }))
+ await openRowMenu(plugin({ enabled: 0, status: 'inactive' }));
- expect(screen.getByText('View error log')).toBeInTheDocument()
- expect(screen.queryByText('Restart')).not.toBeInTheDocument()
- })
+ expect(screen.getByText('View error log')).toBeInTheDocument();
+ expect(screen.queryByText('Restart')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-049: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => {
// An admin must never be invited to widen egress for a plugin that didn't ask for it
// — same rule the row chip already follows.
- await openRowMenu(plugin({ operatorEgress: false }))
+ await openRowMenu(plugin({ operatorEgress: false }));
- expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
- expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument()
- })
+ expect(screen.getByText('View error log')).toBeInTheDocument(); // menu is open
+ expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-050: a long host list scrolls inside the dialog instead of growing past the viewport (#2159)', async () => {
- server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
- HttpResponse.json({ supported: true, hosts: ['gotify.lan', 'ntfy.lan'] })))
- await openRowMenu()
+ server.use(
+ http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
+ HttpResponse.json({ supported: true, hosts: ['gotify.lan', 'ntfy.lan'] })
+ )
+ );
+ await openRowMenu();
- fireEvent.click(screen.getByText('Allowed hosts'))
+ fireEvent.click(screen.getByText('Allowed hosts'));
// The card is capped and its body scrolls, so the input, the Add button and the
// restart note below the list stay reachable however many hosts the admin entered.
- 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-full')
- })
-})
+ 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-full');
+ });
+});
describe('AdminPluginsPanel — enabling a plugin', () => {
it('FE-COMP-PLUGINS-PANEL-021: turning a plugin off deactivates it', async () => {
- let called = false
- panelWith([plugin({ enabled: 1, status: 'active' })])
- server.use(http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { called = true; return HttpResponse.json({ ok: true }) }))
- withToast()
- await screen.findByText('Gotify')
+ let called = false;
+ panelWith([plugin({ enabled: 1, status: 'active' })]);
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/deactivate', () => {
+ called = true;
+ return HttpResponse.json({ ok: true });
+ })
+ );
+ withToast();
+ await screen.findByText('Gotify');
- fireEvent.click(rowToggle('Gotify'))
+ fireEvent.click(rowToggle('Gotify'));
- expect(await screen.findByText('Plugin deactivated')).toBeInTheDocument()
- expect(called).toBe(true)
- })
+ expect(await screen.findByText('Plugin deactivated')).toBeInTheDocument();
+ expect(called).toBe(true);
+ });
it('FE-COMP-PLUGINS-PANEL-022: turning one on also reports the dependencies it enabled first', async () => {
panelWith([
- plugin({ enabled: 0, status: 'inactive', dependencies: { pluginDependencies: [{ id: 'trek-core', version: '^1.0.0' }] } }),
+ plugin({
+ enabled: 0,
+ status: 'inactive',
+ dependencies: { pluginDependencies: [{ id: 'trek-core', version: '^1.0.0' }] },
+ }),
plugin({ id: 'trek-core', name: 'Core', enabled: 0, status: 'inactive' }),
- ])
- server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => HttpResponse.json({ ok: true })))
- withToast()
- await screen.findByText('Gotify')
+ ]);
+ server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => HttpResponse.json({ ok: true })));
+ withToast();
+ await screen.findByText('Gotify');
- fireEvent.click(rowToggle('Gotify'))
+ fireEvent.click(rowToggle('Gotify'));
- expect(await screen.findByText('Plugin activated')).toBeInTheDocument()
- expect(await screen.findByText(/enabled required plugin\(s\) first: core/i)).toBeInTheDocument()
- })
+ expect(await screen.findByText('Plugin activated')).toBeInTheDocument();
+ expect(await screen.findByText(/enabled required plugin\(s\) first: core/i)).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-023: a disabled required addon is reported as a toast, not a dialog', async () => {
- panelWith([plugin({ enabled: 0, status: 'inactive' })])
- server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
- HttpResponse.json({ code: 'ADDON_DISABLED', addons: ['journey'] }, { status: 409 })))
- withToast()
- await screen.findByText('Gotify')
-
- fireEvent.click(rowToggle('Gotify'))
-
- expect(await screen.findByText(/enable the required addon\(s\) first: journey/i)).toBeInTheDocument()
- })
-
- it('FE-COMP-PLUGINS-PANEL-024: any other activation failure surfaces the server message', async () => {
- panelWith([plugin({ enabled: 0, status: 'inactive' })])
- server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
- HttpResponse.json({ code: 'DEPENDENCY_CYCLE', error: 'circular dependency' }, { status: 409 })))
- withToast()
- await screen.findByText('Gotify')
-
- fireEvent.click(rowToggle('Gotify'))
-
- expect(await screen.findByText('circular dependency')).toBeInTheDocument()
- })
-
- it('FE-COMP-PLUGINS-PANEL-025: a missing dependency opens the resolve dialog and one click fixes it', async () => {
- let activates = 0
- let installed: unknown = null
- panelWith([plugin({ enabled: 0, status: 'inactive' })])
- server.use(
- http.post('*/api/admin/plugins/trek-gotify/activate', () => {
- activates += 1
- return activates === 1
- ? HttpResponse.json({
- code: 'DEPENDENCY_MISSING',
- missing: [{ id: 'trek-core', version: '^1.0.0' }],
- versionMismatch: [{ id: 'trek-old', wanted: '^2.0.0', installed: '1.0.0' }],
- }, { status: 409 })
- : HttpResponse.json({ ok: true })
- }),
- http.post('*/api/admin/plugins/install', async ({ request }) => {
- installed = await request.json()
- return HttpResponse.json({ installed: ['trek-core'], requiredAddons: ['journey'] })
- }),
- )
- withToast()
- await screen.findByText('Gotify')
-
- fireEvent.click(rowToggle('Gotify'))
- expect(await screen.findByText('Missing dependencies')).toBeInTheDocument()
- expect(screen.getByText('Requires ^1.0.0')).toBeInTheDocument()
- expect(screen.getByText('Needs ^2.0.0 — 1.0.0 is installed')).toBeInTheDocument()
- expect(screen.getByText(/downloads the latest compatible version/i)).toBeInTheDocument()
-
- fireEvent.click(screen.getByRole('button', { name: /^download$/i }))
-
- expect(await screen.findByText('Downloaded trek-core')).toBeInTheDocument()
- expect(installed).toEqual({ id: 'trek-core', constraint: '^1.0.0', withDependencies: true })
- expect(await screen.findByText(/enable the required addon\(s\) first: journey/i)).toBeInTheDocument()
- await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument())
- })
-
- it('FE-COMP-PLUGINS-PANEL-026: the resolve dialog can be dismissed without downloading anything', async () => {
- let installs = 0
- panelWith([plugin({ enabled: 0, status: 'inactive' })])
+ panelWith([plugin({ enabled: 0, status: 'inactive' })]);
server.use(
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
- HttpResponse.json({ code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-core', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
- http.post('*/api/admin/plugins/install', () => { installs += 1; return HttpResponse.json({}) }),
- )
- withToast()
- await screen.findByText('Gotify')
+ HttpResponse.json({ code: 'ADDON_DISABLED', addons: ['journey'] }, { status: 409 })
+ )
+ );
+ withToast();
+ await screen.findByText('Gotify');
- fireEvent.click(rowToggle('Gotify'))
+ fireEvent.click(rowToggle('Gotify'));
+
+ expect(await screen.findByText(/enable the required addon\(s\) first: journey/i)).toBeInTheDocument();
+ });
+
+ it('FE-COMP-PLUGINS-PANEL-024: any other activation failure surfaces the server message', async () => {
+ panelWith([plugin({ enabled: 0, status: 'inactive' })]);
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/activate', () =>
+ HttpResponse.json({ code: 'DEPENDENCY_CYCLE', error: 'circular dependency' }, { status: 409 })
+ )
+ );
+ withToast();
+ await screen.findByText('Gotify');
+
+ fireEvent.click(rowToggle('Gotify'));
+
+ expect(await screen.findByText('circular dependency')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-PLUGINS-PANEL-025: a missing dependency opens the resolve dialog and one click fixes it', async () => {
+ let activates = 0;
+ let installed: unknown = null;
+ panelWith([plugin({ enabled: 0, status: 'inactive' })]);
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/activate', () => {
+ activates += 1;
+ return activates === 1
+ ? HttpResponse.json(
+ {
+ code: 'DEPENDENCY_MISSING',
+ missing: [{ id: 'trek-core', version: '^1.0.0' }],
+ versionMismatch: [{ id: 'trek-old', wanted: '^2.0.0', installed: '1.0.0' }],
+ },
+ { status: 409 }
+ )
+ : HttpResponse.json({ ok: true });
+ }),
+ http.post('*/api/admin/plugins/install', async ({ request }) => {
+ installed = await request.json();
+ return HttpResponse.json({ installed: ['trek-core'], requiredAddons: ['journey'] });
+ })
+ );
+ withToast();
+ await screen.findByText('Gotify');
+
+ fireEvent.click(rowToggle('Gotify'));
+ expect(await screen.findByText('Missing dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Requires ^1.0.0')).toBeInTheDocument();
+ expect(screen.getByText('Needs ^2.0.0 — 1.0.0 is installed')).toBeInTheDocument();
+ expect(screen.getByText(/downloads the latest compatible version/i)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /^download$/i }));
+
+ expect(await screen.findByText('Downloaded trek-core')).toBeInTheDocument();
+ expect(installed).toEqual({ id: 'trek-core', constraint: '^1.0.0', withDependencies: true });
+ expect(await screen.findByText(/enable the required addon\(s\) first: journey/i)).toBeInTheDocument();
+ await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument());
+ });
+
+ it('FE-COMP-PLUGINS-PANEL-026: the resolve dialog can be dismissed without downloading anything', async () => {
+ let installs = 0;
+ panelWith([plugin({ enabled: 0, status: 'inactive' })]);
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/activate', () =>
+ HttpResponse.json(
+ { code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-core', version: '^1.0.0' }], versionMismatch: [] },
+ { status: 409 }
+ )
+ ),
+ http.post('*/api/admin/plugins/install', () => {
+ installs += 1;
+ return HttpResponse.json({});
+ })
+ );
+ withToast();
+ await screen.findByText('Gotify');
+
+ fireEvent.click(rowToggle('Gotify'));
// The dependency list scrolls; Cancel sits outside it, so it never leaves the viewport (#2159).
- const body = (await screen.findByText('trek-core')).closest('.overflow-y-auto')
- expect(body).not.toBeNull()
- expect(body).not.toContainElement(screen.getByRole('button', { name: /^cancel$/i }))
- fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
+ const body = (await screen.findByText('trek-core')).closest('.overflow-y-auto');
+ expect(body).not.toBeNull();
+ expect(body).not.toContainElement(screen.getByRole('button', { name: /^cancel$/i }));
+ fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
- await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument())
- expect(installs).toBe(0)
- })
+ await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument());
+ expect(installs).toBe(0);
+ });
it('FE-COMP-PLUGINS-PANEL-027: widened permissions route to the consent dialog, which can be deferred', async () => {
- panelWith([plugin({ enabled: 0, status: 'inactive', source_repo: 'acme/gotify', signed: false })])
- server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
- HttpResponse.json({ code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips', 'weird:perm'], newEgress: ['api.acme.io'] }, { status: 409 })))
- withToast()
- await screen.findByText('Gotify')
+ panelWith([plugin({ enabled: 0, status: 'inactive', source_repo: 'acme/gotify', signed: false })]);
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/activate', () =>
+ HttpResponse.json(
+ { code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips', 'weird:perm'], newEgress: ['api.acme.io'] },
+ { status: 409 }
+ )
+ )
+ );
+ withToast();
+ await screen.findByText('Gotify');
- fireEvent.click(rowToggle('Gotify'))
+ fireEvent.click(rowToggle('Gotify'));
- expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument()
- expect(screen.getByText('Read trips the acting user can access')).toBeInTheDocument()
+ expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
+ expect(screen.getByText('Read trips the acting user can access')).toBeInTheDocument();
// An unknown permission is still shown — as its raw code
- expect(screen.getByText('weird:perm')).toBeInTheDocument()
- expect(screen.getByText('api.acme.io')).toBeInTheDocument()
+ expect(screen.getByText('weird:perm')).toBeInTheDocument();
+ expect(screen.getByText('api.acme.io')).toBeInTheDocument();
// A long permission list scrolls inside the dialog; the decision buttons sit outside
// the scroll container, so they never leave the viewport (#2159).
- const body = screen.getByText('Read trips the acting user can access').closest('.overflow-y-auto')
- expect(body).not.toBeNull()
- expect(body).not.toContainElement(screen.getByRole('button', { name: /keep off for now/i }))
+ const body = screen.getByText('Read trips the acting user can access').closest('.overflow-y-auto');
+ expect(body).not.toBeNull();
+ expect(body).not.toContainElement(screen.getByRole('button', { name: /keep off for now/i }));
- fireEvent.click(screen.getByRole('button', { name: /keep off for now/i }))
- expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument()
- })
+ fireEvent.click(screen.getByRole('button', { name: /keep off for now/i }));
+ expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-028: approving consent re-activates with the consent flag', async () => {
- const bodies: unknown[] = []
- panelWith([plugin({ enabled: 0, status: 'inactive' })])
- server.use(http.post('*/api/admin/plugins/trek-gotify/activate', async ({ request }) => {
- bodies.push(await request.json())
- return bodies.length === 1
- ? HttpResponse.json({ code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 })
- : HttpResponse.json({ ok: true })
- }))
- withToast()
- await screen.findByText('Gotify')
+ const bodies: unknown[] = [];
+ panelWith([plugin({ enabled: 0, status: 'inactive' })]);
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/activate', async ({ request }) => {
+ bodies.push(await request.json());
+ return bodies.length === 1
+ ? HttpResponse.json(
+ { code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] },
+ { status: 409 }
+ )
+ : HttpResponse.json({ ok: true });
+ })
+ );
+ withToast();
+ await screen.findByText('Gotify');
- fireEvent.click(rowToggle('Gotify'))
- fireEvent.click(await screen.findByRole('button', { name: /approve & turn on/i }))
+ fireEvent.click(rowToggle('Gotify'));
+ fireEvent.click(await screen.findByRole('button', { name: /approve & turn on/i }));
- expect(await screen.findByText('Plugin updated')).toBeInTheDocument()
- expect(bodies[1]).toEqual({ consent: true })
- })
-})
+ expect(await screen.findByText('Plugin updated')).toBeInTheDocument();
+ expect(bodies[1]).toEqual({ consent: true });
+ });
+});
describe('AdminPluginsPanel — updates', () => {
- const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false })
+ const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false });
it('FE-COMP-PLUGINS-PANEL-029: the update banner updates every outdated plugin at once', async () => {
- const updated: string[] = []
+ const updated: string[] = [];
panelWith(
- [outdated, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })],
- { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] },
- )
- server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
- updated.push(String(params.id))
- return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
- }))
- withToast()
+ [
+ outdated,
+ plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false }),
+ ],
+ { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] }
+ );
+ server.use(
+ http.post('*/api/admin/plugins/:id/update', ({ params }) => {
+ updated.push(String(params.id));
+ return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
+ })
+ );
+ withToast();
- expect(await screen.findByText('2 updates available for your plugins.')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: /update all/i }))
+ expect(await screen.findByText('2 updates available for your plugins.')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /update all/i }));
- await waitFor(() => expect([...updated].sort()).toEqual(['trek-gotify', 'trek-ntfy']))
- expect((await screen.findAllByText('Plugin updated')).length).toBeGreaterThan(0)
- })
+ await waitFor(() => expect([...updated].sort()).toEqual(['trek-gotify', 'trek-ntfy']));
+ expect((await screen.findAllByText('Plugin updated')).length).toBeGreaterThan(0);
+ });
it('FE-COMP-PLUGINS-PANEL-030: an update that widens permissions queues the consent dialog', async () => {
- panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0', signed: false })] })
- server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
- HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:users'], newEgress: [] })))
- withToast()
+ panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0', signed: false })] });
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/update', () =>
+ HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:users'], newEgress: [] })
+ )
+ );
+ withToast();
- fireEvent.click(await screen.findByRole('button', { name: /update → v2\.0\.0/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /update → v2\.0\.0/i }));
- expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument()
- expect(screen.getByText(/read basic profile info/i)).toBeInTheDocument()
+ expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
+ expect(screen.getByText(/read basic profile info/i)).toBeInTheDocument();
// The registry says the new code is unsigned — say so where the rights get widened
- expect(screen.getByText(/nothing ties this version to its author/i)).toBeInTheDocument()
- })
+ expect(screen.getByText(/nothing ties this version to its author/i)).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-031: a failed update that is not a signature refusal is a plain toast', async () => {
- panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] })
- server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
- HttpResponse.json({ error: 'registry unreachable' }, { status: 502 })))
- withToast()
+ panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] });
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/update', () =>
+ HttpResponse.json({ error: 'registry unreachable' }, { status: 502 })
+ )
+ );
+ withToast();
- fireEvent.click(await screen.findByRole('button', { name: /update → v2\.0\.0/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /update → v2\.0\.0/i }));
- expect(await screen.findByText('registry unreachable')).toBeInTheDocument()
- })
+ expect(await screen.findByText('registry unreachable')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-032: a re-trusted update that widens rights still asks for consent', async () => {
- panelWith([plugin({
- source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDAAAAA…BBBBBBBB', operatorEgress: false,
- updateBlock: { code: 'SIGNATURE_KEY_CHANGED', version: '2.0.0', detail: 'key rotated' },
- })], { registry: [registryEntry({ latest: '2.0.0' })] })
- server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () =>
- HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: ['api.acme.io'] })))
- withToast()
+ panelWith(
+ [
+ plugin({
+ source_repo: 'acme/gotify',
+ version: '1.0.0',
+ keyFingerprint: 'OLDAAAAA…BBBBBBBB',
+ operatorEgress: false,
+ updateBlock: { code: 'SIGNATURE_KEY_CHANGED', version: '2.0.0', detail: 'key rotated' },
+ }),
+ ],
+ { registry: [registryEntry({ latest: '2.0.0' })] }
+ );
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/retrust', () =>
+ HttpResponse.json({
+ version: '2.0.0',
+ activated: false,
+ newPermissions: ['db:read:trips'],
+ newEgress: ['api.acme.io'],
+ })
+ )
+ );
+ withToast();
- fireEvent.click(await screen.findByRole('button', { name: /^review$/i }))
- fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /^review$/i }));
+ fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }));
- expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument()
- expect(screen.getByText('api.acme.io')).toBeInTheDocument()
- })
+ expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
+ expect(screen.getByText('api.acme.io')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-033: a failed re-trust surfaces the server message', async () => {
- panelWith([plugin({
- source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDAAAAA…BBBBBBBB', operatorEgress: false,
- updateBlock: { code: 'SIGNATURE_KEY_CHANGED', version: '2.0.0', detail: 'key rotated' },
- })], { registry: [registryEntry({ latest: '2.0.0' })] })
- server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () =>
- HttpResponse.json({ error: 'key does not match' }, { status: 400 })))
- withToast()
+ panelWith(
+ [
+ plugin({
+ source_repo: 'acme/gotify',
+ version: '1.0.0',
+ keyFingerprint: 'OLDAAAAA…BBBBBBBB',
+ operatorEgress: false,
+ updateBlock: { code: 'SIGNATURE_KEY_CHANGED', version: '2.0.0', detail: 'key rotated' },
+ }),
+ ],
+ { registry: [registryEntry({ latest: '2.0.0' })] }
+ );
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/retrust', () =>
+ HttpResponse.json({ error: 'key does not match' }, { status: 400 })
+ )
+ );
+ withToast();
- fireEvent.click(await screen.findByRole('button', { name: /^review$/i }))
- fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /^review$/i }));
+ fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }));
- expect(await screen.findByText('key does not match')).toBeInTheDocument()
- })
+ expect(await screen.findByText('key does not match')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-034a: a stable release counts as newer than the prerelease of the same version', async () => {
panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0-beta.1', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
- })
- render( )
+ });
+ render( );
- expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
- })
+ expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-034: a block with no recorded version stands while the registry is silent', async () => {
- panelWith([plugin({
- source_repo: 'acme/gotify', operatorEgress: false,
- updateBlock: { code: 'SIGNATURE_INVALID', version: null, detail: 'digest mismatch' },
- })], { registry: [] })
- render( )
+ panelWith(
+ [
+ plugin({
+ source_repo: 'acme/gotify',
+ operatorEgress: false,
+ updateBlock: { code: 'SIGNATURE_INVALID', version: null, detail: 'digest mismatch' },
+ }),
+ ],
+ { registry: [] }
+ );
+ render( );
- expect(await screen.findByText(/update blocked — digest mismatch/i)).toBeInTheDocument()
- })
-})
+ expect(await screen.findByText(/update blocked — digest mismatch/i)).toBeInTheDocument();
+ });
+});
/**
* Update availability must follow what this TREK can actually INSTALL (latestCompatible,
@@ -1158,56 +1405,64 @@ describe('AdminPluginsPanel — updates', () => {
* versions the update endpoint would refuse nags the admin toward a guaranteed 400.
*/
describe('AdminPluginsPanel — compatible updates only', () => {
- const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false })
+ const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false });
it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
- })
- render( )
- await screen.findByText('Gotify')
+ });
+ render( );
+ await screen.findByText('Gotify');
- expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
- expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
- })
+ expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument();
+ expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
- })
- render( )
+ });
+ render( );
- expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
- expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument()
- expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument()
- })
+ expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument();
+ expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
- })
- render( )
+ });
+ render( );
- expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
- })
+ expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => {
panelWith([outdated], {
- registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })],
- })
- render( )
+ registry: [
+ registryEntry({
+ latest: '2.0.0',
+ latestCompatible: null,
+ trek: null,
+ minTrekVersion: '4.0.0',
+ hostVersion: '3.3.0',
+ }),
+ ],
+ });
+ render( );
- expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
- })
+ expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => {
- panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] })
- render( )
+ panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] });
+ render( );
- expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
- expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument()
- })
-})
+ expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument();
+ expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument();
+ });
+});
/**
* The update hold (#plugins): a deliberate non-latest install sets updateHold on the row,
@@ -1215,51 +1470,58 @@ describe('AdminPluginsPanel — compatible updates only', () => {
* made must not be nagged straight back. The row says so and offers to resume.
*/
describe('AdminPluginsPanel — update hold', () => {
- const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true })
+ const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true });
it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => {
- panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
- render( )
- await screen.findByText('Gotify')
+ panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] });
+ render( );
+ await screen.findByText('Gotify');
- expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
- expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
- expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument()
- })
+ expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument();
+ expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => {
- let resumed = false
- panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
- server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
- resumed = true
- return HttpResponse.json({ updateHold: false })
- }))
- withToast()
+ let resumed = false;
+ panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] });
+ server.use(
+ http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
+ resumed = true;
+ return HttpResponse.json({ updateHold: false });
+ })
+ );
+ withToast();
- fireEvent.click(await screen.findByRole('button', { name: /resume updates/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /resume updates/i }));
- await waitFor(() => expect(resumed).toBe(true))
- expect(await screen.findByText('Updates resumed')).toBeInTheDocument()
- })
+ await waitFor(() => expect(resumed).toBe(true));
+ expect(await screen.findByText('Updates resumed')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => {
- const updated: string[] = []
+ const updated: string[] = [];
panelWith(
- [held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })],
- { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] },
- )
- server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
- updated.push(String(params.id))
- return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] })
- }))
- withToast()
+ [
+ held,
+ plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false }),
+ ],
+ { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] }
+ );
+ server.use(
+ http.post('*/api/admin/plugins/:id/update', ({ params }) => {
+ updated.push(String(params.id));
+ return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] });
+ })
+ );
+ withToast();
- expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: /update all/i }))
+ expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /update all/i }));
- await waitFor(() => expect(updated).toEqual(['trek-ntfy']))
- })
-})
+ await waitFor(() => expect(updated).toEqual(['trek-ntfy']));
+ });
+});
/**
* The version picker (#plugins): install any published version from the Discover detail
@@ -1272,197 +1534,274 @@ describe('AdminPluginsPanel — version picker', () => {
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
- ]
+ ];
const detailWithVersions = () => ({
- ...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
- size: 1024, publishedAt: null, manifest: null, versions: VERSIONS,
- })
+ ...registryEntry({
+ latest: '3.0.0',
+ latestCompatible: '2.0.0',
+ trek: '>=4.0.0',
+ hostVersion: '3.3.0',
+ compatible: false,
+ }),
+ size: 1024,
+ publishedAt: null,
+ manifest: null,
+ versions: VERSIONS,
+ });
function mockDiscoverDetail() {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
http.get('*/api/admin/plugins/registry', () =>
- HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])),
- http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
- )
+ HttpResponse.json([
+ registryEntry({
+ latest: '3.0.0',
+ latestCompatible: '2.0.0',
+ trek: '>=4.0.0',
+ hostVersion: '3.3.0',
+ compatible: false,
+ }),
+ ])
+ ),
+ http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions()))
+ );
}
it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => {
- let body: unknown = null
- mockDiscoverDetail()
- server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
- body = await request.json()
- return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' })
- }))
- withToast()
- const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
- fireEvent.click(tabs[0])
- fireEvent.click(await screen.findByText('Gotify'))
+ let body: unknown = null;
+ mockDiscoverDetail();
+ server.use(
+ http.post('*/api/admin/plugins/install', async ({ request }) => {
+ body = await request.json();
+ return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' });
+ })
+ );
+ withToast();
+ const tabs = await screen.findAllByRole('tab', { name: /^discover/i });
+ fireEvent.click(tabs[0]);
+ fireEvent.click(await screen.findByText('Gotify'));
- fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }));
- await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }))
- })
+ await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }));
+ });
it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => {
- mockDiscoverDetail()
- render( )
- const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
- fireEvent.click(tabs[0])
- fireEvent.click(await screen.findByText('Gotify'))
+ mockDiscoverDetail();
+ render( );
+ const tabs = await screen.findAllByRole('tab', { name: /^discover/i });
+ fireEvent.click(tabs[0]);
+ fireEvent.click(await screen.findByText('Gotify'));
- expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument()
- expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument()
- })
+ expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => {
- let body: unknown = null
+ let body: unknown = null;
panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
- })
+ });
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
- body = await request.json()
- return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] })
- }),
- )
- withToast()
- fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
- fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
- fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }))
+ body = await request.json();
+ return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] });
+ })
+ );
+ withToast();
+ fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
+ fireEvent.click(await screen.findByRole('button', { name: /change version/i }));
+ fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }));
// The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot.
- expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: /roll back/i }))
+ expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /roll back/i }));
- await waitFor(() => expect(body).toEqual({ version: '1.5.0' }))
- })
+ await waitFor(() => expect(body).toEqual({ version: '1.5.0' }));
+ });
it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => {
- let body: unknown = null
+ let body: unknown = null;
panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
- })
+ });
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
- body = await request.json()
- return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
- }),
- )
- withToast()
- fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
- fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
- fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i }))
+ body = await request.json();
+ return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
+ })
+ );
+ withToast();
+ fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
+ fireEvent.click(await screen.findByRole('button', { name: /change version/i }));
+ fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i }));
- await waitFor(() => expect(body).toEqual({ version: '2.0.0' }))
- expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument()
- })
+ await waitFor(() => expect(body).toEqual({ version: '2.0.0' }));
+ expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => {
- panelWith([
- plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }),
- plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }),
- ], { registry: [registryEntry({ latest: '2.0.0' })] })
- server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())))
- withToast()
+ panelWith(
+ [
+ plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }),
+ plugin({
+ id: 'side-load',
+ name: 'Sideload',
+ source_repo: 'local:upload',
+ version: '1.0.0',
+ operatorEgress: false,
+ }),
+ ],
+ { registry: [registryEntry({ latest: '2.0.0' })] }
+ );
+ server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())));
+ withToast();
- fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load'))
- expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument()
- fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close
+ fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load'));
+ expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')); // close
- fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
- fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
+ fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
+ fireEvent.click(await screen.findByRole('button', { name: /change version/i }));
- const current = await screen.findByTestId('version-row-2.0.0')
- expect(within(current).getByText('Installed')).toBeInTheDocument()
- expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument()
- })
-})
+ const current = await screen.findByTestId('version-row-2.0.0');
+ expect(within(current).getByText('Installed')).toBeInTheDocument();
+ expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument();
+ });
+});
describe('AdminPluginsPanel — capability and dependency chips', () => {
it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => {
- panelWith([plugin({
- operatorEgress: false,
- permissions: JSON.stringify([
- 'db:read:trips', 'db:read:users', 'db:write:costs', 'db:read:packing', 'db:read:files',
- 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:meta',
- 'ws:broadcast:trip', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider',
- 'hook:trip-warning-provider', 'hook:map-layer-provider', 'hook:route-provider',
- 'hook:day-schedule-provider', 'geolocation:read', 'events:subscribe', 'http:outbound:api.acme.io',
- ]),
- capabilities: JSON.stringify({ widget: { slot: 'hero' }, tripPage: { replaces: ['places'] } }),
- })])
- render( )
- await screen.findByText('Gotify')
+ panelWith([
+ plugin({
+ operatorEgress: false,
+ permissions: JSON.stringify([
+ 'db:read:trips',
+ 'db:read:users',
+ 'db:write:costs',
+ 'db:read:packing',
+ 'db:read:files',
+ 'db:write:places',
+ 'db:write:days',
+ 'db:write:itinerary',
+ 'db:write:trips',
+ 'db:meta',
+ 'ws:broadcast:trip',
+ 'hook:photo-provider',
+ 'hook:calendar-source',
+ 'hook:place-detail-provider',
+ 'hook:trip-warning-provider',
+ 'hook:map-layer-provider',
+ 'hook:route-provider',
+ 'hook:day-schedule-provider',
+ 'geolocation:read',
+ 'events:subscribe',
+ 'http:outbound:api.acme.io',
+ ]),
+ capabilities: JSON.stringify({ widget: { slot: 'hero' }, tripPage: { replaces: ['places'] } }),
+ }),
+ ]);
+ render( );
+ await screen.findByText('Gotify');
for (const label of [
- 'Reads your trips', 'Reads basic profiles', 'Adds costs', 'Reads packing lists', 'Reads trip files',
- 'Edits places', 'Edits days', 'Edits itinerary', 'Edits trips', 'Adds metadata',
- 'Boarding-pass widget', 'Replaces planner tabs', 'Real-time updates', 'Provides photos',
- 'Provides calendar events', 'Enriches places', 'Flags issues', 'Draws on the map', 'Offers routing',
- 'Adds plan times', 'Reads your position', 'Reacts to activity', 'api.acme.io',
+ 'Reads your trips',
+ 'Reads basic profiles',
+ 'Adds costs',
+ 'Reads packing lists',
+ 'Reads trip files',
+ 'Edits places',
+ 'Edits days',
+ 'Edits itinerary',
+ 'Edits trips',
+ 'Adds metadata',
+ 'Boarding-pass widget',
+ 'Replaces planner tabs',
+ 'Real-time updates',
+ 'Provides photos',
+ 'Provides calendar events',
+ 'Enriches places',
+ 'Flags issues',
+ 'Draws on the map',
+ 'Offers routing',
+ 'Adds plan times',
+ 'Reads your position',
+ 'Reacts to activity',
+ 'api.acme.io',
]) {
- expect(screen.getByText(label)).toBeInTheDocument()
+ expect(screen.getByText(label)).toBeInTheDocument();
}
- })
+ });
it('FE-COMP-PLUGINS-PANEL-036: read-only costs and a slotted widget each get their own wording', async () => {
- panelWith([plugin({
- operatorEgress: false,
- permissions: JSON.stringify(['db:read:costs']),
- capabilities: JSON.stringify({ widget: { slot: 'place-detail' } }),
- })])
- render( )
- await screen.findByText('Gotify')
+ panelWith([
+ plugin({
+ operatorEgress: false,
+ permissions: JSON.stringify(['db:read:costs']),
+ capabilities: JSON.stringify({ widget: { slot: 'place-detail' } }),
+ }),
+ ]);
+ render( );
+ await screen.findByText('Gotify');
- expect(screen.getByText('Reads your costs')).toBeInTheDocument()
- expect(screen.getByText('Place detail')).toBeInTheDocument()
- expect(screen.queryByText('Adds costs')).not.toBeInTheDocument()
- })
+ expect(screen.getByText('Reads your costs')).toBeInTheDocument();
+ expect(screen.getByText('Place detail')).toBeInTheDocument();
+ expect(screen.queryByText('Adds costs')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-037: an unparseable capabilities blob degrades to the plain widget chip', async () => {
- panelWith([plugin({ operatorEgress: false, permissions: 'not json', capabilities: JSON.stringify({ widget: {} }) })])
- render( )
- await screen.findByText('Gotify')
+ panelWith([
+ plugin({ operatorEgress: false, permissions: 'not json', capabilities: JSON.stringify({ widget: {} }) }),
+ ]);
+ render( );
+ await screen.findByText('Gotify');
- expect(screen.getByText('Dashboard widget')).toBeInTheDocument()
- })
+ expect(screen.getByText('Dashboard widget')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-038: dependency chips flag the addon and plugin the row is waiting on', async () => {
- panelWith([plugin({
- operatorEgress: false,
- dependencies: { requiredAddons: ['journey'], pluginDependencies: [{ id: 'trek-core', version: '^1.0.0' }] },
- dependencyIssues: { disabledAddons: ['journey'], missing: [{ id: 'trek-core', version: '^1.0.0' }], versionMismatch: [] },
- })])
- render( )
+ panelWith([
+ plugin({
+ operatorEgress: false,
+ dependencies: { requiredAddons: ['journey'], pluginDependencies: [{ id: 'trek-core', version: '^1.0.0' }] },
+ dependencyIssues: {
+ disabledAddons: ['journey'],
+ missing: [{ id: 'trek-core', version: '^1.0.0' }],
+ versionMismatch: [],
+ },
+ }),
+ ]);
+ render( );
- expect(await screen.findByText('Requires journey')).toBeInTheDocument()
- expect(screen.getByText('Needs trek-core ^1.0.0')).toBeInTheDocument()
- })
+ expect(await screen.findByText('Requires journey')).toBeInTheDocument();
+ expect(screen.getByText('Needs trek-core ^1.0.0')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-039: a plugin that names no TREK range says so', async () => {
- panelWith([plugin({ operatorEgress: false, dependencyStatus: 'hostIncompatible' })])
- render( )
+ panelWith([plugin({ operatorEgress: false, dependencyStatus: 'hostIncompatible' })]);
+ render( );
- expect(await screen.findByText('Does not say which TREK versions it supports')).toBeInTheDocument()
- })
+ expect(await screen.findByText('Does not say which TREK versions it supports')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-051: a plugin adding explore-pill categories says so on its row (#1781)', async () => {
- panelWith([plugin({
- operatorEgress: false,
- permissions: JSON.stringify(['hook:poi-category-provider']),
- capabilities: JSON.stringify({
- poiCategories: [{ id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' }],
+ panelWith([
+ plugin({
+ operatorEgress: false,
+ permissions: JSON.stringify(['hook:poi-category-provider']),
+ capabilities: JSON.stringify({
+ poiCategories: [{ id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' }],
+ }),
}),
- })])
- render( )
- await screen.findByText('Gotify')
+ ]);
+ render( );
+ await screen.findByText('Gotify');
- expect(screen.getByText('Adds map categories')).toBeInTheDocument()
- })
-})
+ expect(screen.getByText('Adds map categories')).toBeInTheDocument();
+ });
+});
describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
function discoverWith(entry: Record, detail?: Record) {
@@ -1470,796 +1809,996 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry(entry)])),
http.get('*/api/admin/plugins/registry/trek-gotify', () =>
- detail ? HttpResponse.json(detail) : HttpResponse.json({ error: 'gone' }, { status: 404 })),
- )
+ detail ? HttpResponse.json(detail) : HttpResponse.json({ error: 'gone' }, { status: 404 })
+ )
+ );
}
function manifestDetail(over: Record = {}, manifest: Record = {}) {
return {
- id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
- type: 'integration', tags: [], size: 4096, publishedAt: null, latest: '2.0.0',
- manifest: { permissions: [], egress: [], settings: [], license: 'MIT', icon: null, operatorEgress: false, ...manifest },
+ id: 'trek-gotify',
+ name: 'Gotify',
+ author: 'Acme',
+ description: 'Push',
+ repo: 'acme/gotify',
+ type: 'integration',
+ tags: [],
+ size: 4096,
+ publishedAt: null,
+ latest: '2.0.0',
+ manifest: {
+ permissions: [],
+ egress: [],
+ settings: [],
+ license: 'MIT',
+ icon: null,
+ operatorEgress: false,
+ ...manifest,
+ },
...over,
- }
+ };
}
it('FE-COMP-PLUGINS-PANEL-040: a card shows its compact download count and falls back on a broken shot', async () => {
- discoverWith({ downloadCount: 1234, screenshotUrl: 'https://example.com/shot.png', reviewedAt: '2026-01-05T00:00:00Z' })
- const { container } = render( )
- await clickDiscover()
+ discoverWith({
+ downloadCount: 1234,
+ screenshotUrl: 'https://example.com/shot.png',
+ reviewedAt: '2026-01-05T00:00:00Z',
+ });
+ const { container } = render( );
+ await clickDiscover();
- expect(await screen.findByText('1.2k')).toBeInTheDocument()
- const img = container.querySelector('img') as HTMLImageElement
- expect(screen.getByText('Reviewed')).toBeInTheDocument()
- expect(img).toBeInTheDocument()
- fireEvent.error(img)
- await waitFor(() => expect(container.querySelector('img')).not.toBeInTheDocument())
- })
+ expect(await screen.findByText('1.2k')).toBeInTheDocument();
+ const img = container.querySelector('img') as HTMLImageElement;
+ expect(screen.getByText('Reviewed')).toBeInTheDocument();
+ expect(img).toBeInTheDocument();
+ fireEvent.error(img);
+ await waitFor(() => expect(container.querySelector('img')).not.toBeInTheDocument());
+ });
it('FE-COMP-PLUGINS-PANEL-040a: a two-digit download count is printed verbatim', async () => {
- discoverWith({ downloadCount: 42 })
- render( )
- await clickDiscover()
+ discoverWith({ downloadCount: 42 });
+ render( );
+ await clickDiscover();
- expect(await screen.findByText('42')).toBeInTheDocument()
- })
+ expect(await screen.findByText('42')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-041: opening a card by keyboard shows the modal, and a failed lookup says so', async () => {
- discoverWith({})
- render( )
- await clickDiscover()
+ discoverWith({});
+ render( );
+ await clickDiscover();
- const card = (await screen.findByText('Gotify')).closest('[role="button"]') as HTMLElement
- fireEvent.keyDown(card, { key: 'Enter' })
+ const card = (await screen.findByText('Gotify')).closest('[role="button"]') as HTMLElement;
+ fireEvent.keyDown(card, { key: 'Enter' });
- expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument()
- })
+ expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-041a: the card opts out of the global press-scale (#2158)', async () => {
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
// the card shifted the Install button out from under the pointer, so the click
// retargeted onto the card and opened the detail modal instead of installing.
// The data-no-press attribute is the pin.
- discoverWith({})
- render( )
- await clickDiscover()
+ discoverWith({});
+ render( );
+ await clickDiscover();
- expect((await screen.findByText('Gotify')).closest('[role="button"]')).toHaveAttribute('data-no-press')
- })
+ expect((await screen.findByText('Gotify')).closest('[role="button"]')).toHaveAttribute('data-no-press');
+ });
it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => {
discoverWith(
- { downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' },
- manifestDetail({}, {
- permissions: ['db:own'],
- settings: [{ key: 'url', label: 'Server URL', scope: 'instance', required: true }],
- }),
- )
- render( )
- await clickDiscover()
- fireEvent.click(await screen.findByText('Gotify'))
+ {
+ downloadCount: 999_500,
+ reviewedAt: '2026-01-05T00:00:00Z',
+ homepage: 'https://gotify.example',
+ trek: '>=3.2.0',
+ },
+ manifestDetail(
+ {},
+ {
+ permissions: ['db:own'],
+ settings: [{ key: 'url', label: 'Server URL', scope: 'instance', required: true }],
+ }
+ )
+ );
+ render( );
+ await clickDiscover();
+ fireEvent.click(await screen.findByText('Gotify'));
- expect(await screen.findByText('Server URL')).toBeInTheDocument()
- expect(screen.getByText('Instance-wide')).toBeInTheDocument()
- expect(screen.getByText('Required')).toBeInTheDocument()
- expect(screen.getByText('4 KB')).toBeInTheDocument()
- expect(screen.getByText('TREK >=3.2.0')).toBeInTheDocument()
- expect(screen.getByText('Store its own data in an isolated database')).toBeInTheDocument()
- expect(screen.getByRole('link', { name: /homepage/i })).toHaveAttribute('href', 'https://gotify.example')
+ expect(await screen.findByText('Server URL')).toBeInTheDocument();
+ expect(screen.getByText('Instance-wide')).toBeInTheDocument();
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ expect(screen.getByText('4 KB')).toBeInTheDocument();
+ expect(screen.getByText('TREK >=3.2.0')).toBeInTheDocument();
+ expect(screen.getByText('Store its own data in an isolated database')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /homepage/i })).toHaveAttribute('href', 'https://gotify.example');
// 999 500 rounds to a whole megabyte on the card, never "1000k"
- expect(screen.getByText('1M')).toBeInTheDocument()
- })
+ expect(screen.getByText('1M')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-043: a manifest with no readable access says exactly that', async () => {
- discoverWith({ minTrekVersion: '3.2.0' }, manifestDetail({ size: 0 }))
- render( )
- await clickDiscover()
- fireEvent.click(await screen.findByText('Gotify'))
+ discoverWith({ minTrekVersion: '3.2.0' }, manifestDetail({ size: 0 }));
+ render( );
+ await clickDiscover();
+ fireEvent.click(await screen.findByText('Gotify'));
- expect(await screen.findByText('Needs no special access.')).toBeInTheDocument()
- expect(screen.getByText('TREK 3.2.0+')).toBeInTheDocument()
- expect(screen.queryByText('Connects to')).not.toBeInTheDocument()
- expect(screen.queryByText('Setup')).not.toBeInTheDocument()
- })
+ expect(await screen.findByText('Needs no special access.')).toBeInTheDocument();
+ expect(screen.getByText('TREK 3.2.0+')).toBeInTheDocument();
+ expect(screen.queryByText('Connects to')).not.toBeInTheDocument();
+ expect(screen.queryByText('Setup')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-043b: a manifest that omits the lists still renders the modal', async () => {
- const detail = manifestDetail({ size: 0 })
- delete (detail.manifest as Record).egress
- delete (detail.manifest as Record).settings
- delete (detail.manifest as Record).permissions
- discoverWith({}, detail)
- render( )
- await clickDiscover()
- fireEvent.click(await screen.findByText('Gotify'))
+ const detail = manifestDetail({ size: 0 });
+ delete (detail.manifest as Record).egress;
+ delete (detail.manifest as Record).settings;
+ delete (detail.manifest as Record).permissions;
+ discoverWith({}, detail);
+ render( );
+ await clickDiscover();
+ fireEvent.click(await screen.findByText('Gotify'));
- expect(await screen.findByText('Needs no special access.')).toBeInTheDocument()
- expect(screen.queryByText('Connects to')).not.toBeInTheDocument()
- expect(screen.queryByText('Setup')).not.toBeInTheDocument()
- })
+ expect(await screen.findByText('Needs no special access.')).toBeInTheDocument();
+ expect(screen.queryByText('Connects to')).not.toBeInTheDocument();
+ expect(screen.queryByText('Setup')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-044: an incompatible entry explains the blocked button in the modal body', async () => {
- discoverWith({ compatible: false, latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })
- render( )
- await clickDiscover()
- fireEvent.click(await screen.findByText('Gotify'))
+ discoverWith({ compatible: false, latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' });
+ render( );
+ await clickDiscover();
+ fireEvent.click(await screen.findByText('Gotify'));
- const explanations = await screen.findAllByText(/needs trek >=4\.0\.0 — this server runs 3\.3\.0/i)
- expect(explanations.length).toBeGreaterThan(0)
- })
+ const explanations = await screen.findAllByText(/needs trek >=4\.0\.0 — this server runs 3\.3\.0/i);
+ expect(explanations.length).toBeGreaterThan(0);
+ });
it('FE-COMP-PLUGINS-PANEL-045: searching Discover with no hit says the registry has no match', async () => {
- discoverWith({})
- render( )
- await clickDiscover()
- await screen.findByText('Gotify')
+ discoverWith({});
+ render( );
+ await clickDiscover();
+ await screen.findByText('Gotify');
- fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'nothing' } })
+ fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'nothing' } });
- expect(screen.getByText('No plugins in the registry match your search.')).toBeInTheDocument()
- })
+ expect(screen.getByText('No plugins in the registry match your search.')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-046: installing from a card posts the offered version', async () => {
- let body: unknown = null
- discoverWith({ compatible: false, latestCompatible: '1.5.0', trek: '>=4.0.0', hostVersion: '3.3.0' })
- server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
- body = await request.json()
- return HttpResponse.json({ ok: true })
- }))
- withToast()
- await clickDiscover()
+ let body: unknown = null;
+ discoverWith({ compatible: false, latestCompatible: '1.5.0', trek: '>=4.0.0', hostVersion: '3.3.0' });
+ server.use(
+ http.post('*/api/admin/plugins/install', async ({ request }) => {
+ body = await request.json();
+ return HttpResponse.json({ ok: true });
+ })
+ );
+ withToast();
+ await clickDiscover();
- fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }));
- await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }))
- })
+ await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }));
+ });
it('FE-COMP-PLUGINS-PANEL-047: an unreachable registry leaves Discover empty rather than spinning', async () => {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.error()),
- )
- render( )
- await screen.findByText('No plugins installed yet.')
+ http.get('*/api/admin/plugins/registry', () => HttpResponse.error())
+ );
+ render( );
+ await screen.findByText('No plugins installed yet.');
- await clickDiscover()
+ await clickDiscover();
- expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument()
- })
+ expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-052: the detail lists the map categories a plugin adds, before the install (#1781)', async () => {
- discoverWith({}, manifestDetail({}, {
- permissions: ['hook:poi-category-provider'],
- capabilities: {
- poiCategories: [
- { id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' },
- { id: 'swimming', label: 'Swimming spots', icon: 'Waves', color: '#0369a1' },
- ],
- },
- }))
- render( )
- await clickDiscover()
- fireEvent.click(await screen.findByText('Gotify'))
+ discoverWith(
+ {},
+ manifestDetail(
+ {},
+ {
+ permissions: ['hook:poi-category-provider'],
+ capabilities: {
+ poiCategories: [
+ { id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' },
+ { id: 'swimming', label: 'Swimming spots', icon: 'Waves', color: '#0369a1' },
+ ],
+ },
+ }
+ )
+ );
+ render( );
+ await clickDiscover();
+ fireEvent.click(await screen.findByText('Gotify'));
- const title = await screen.findByRole('heading', { name: 'Map categories it adds' })
- const section = title.parentElement as HTMLElement
- expect(within(section).getAllByRole('listitem').map(li => li.textContent)).toEqual(['Trailheads', 'Swimming spots'])
- expect(within(section).getAllByTestId('poi-category-swatch')[0].style.backgroundColor).toBe('rgb(47, 133, 90)')
+ const title = await screen.findByRole('heading', { name: 'Map categories it adds' });
+ const section = title.parentElement as HTMLElement;
+ expect(
+ within(section)
+ .getAllByRole('listitem')
+ .map((li) => li.textContent)
+ ).toEqual(['Trailheads', 'Swimming spots']);
+ expect(within(section).getAllByTestId('poi-category-swatch')[0].style.backgroundColor).toBe('rgb(47, 133, 90)');
// The grant itself is listed with the rest of what the plugin can do.
- expect(screen.getByText('Adds map categories')).toBeInTheDocument()
- })
+ expect(screen.getByText('Adds map categories')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-PANEL-053: declared categories without the grant are not shown, the feed would never serve them', async () => {
- discoverWith({}, manifestDetail({}, {
- permissions: ['hook:search-provider'],
- capabilities: { poiCategories: [{ id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' }] },
- }))
- render( )
- await clickDiscover()
- fireEvent.click(await screen.findByText('Gotify'))
+ discoverWith(
+ {},
+ manifestDetail(
+ {},
+ {
+ permissions: ['hook:search-provider'],
+ capabilities: {
+ poiCategories: [{ id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' }],
+ },
+ }
+ )
+ );
+ render( );
+ await clickDiscover();
+ fireEvent.click(await screen.findByText('Gotify'));
- expect(await screen.findByText('Answers searches')).toBeInTheDocument()
- expect(screen.queryByText('Map categories it adds')).not.toBeInTheDocument()
- expect(screen.queryByText('Trailheads')).not.toBeInTheDocument()
- })
-})
+ expect(await screen.findByText('Answers searches')).toBeInTheDocument();
+ expect(screen.queryByText('Map categories it adds')).not.toBeInTheDocument();
+ expect(screen.queryByText('Trailheads')).not.toBeInTheDocument();
+ });
+});
describe('AdminPluginsPanel — security footer', () => {
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => {
- panelWith([])
- render( )
- await screen.findByText('No plugins installed yet.')
+ panelWith([]);
+ render( );
+ await screen.findByText('No plugins installed yet.');
- expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
- fireEvent.click(screen.getByText('How plugins are contained — and the limits'))
+ expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByText('How plugins are contained — and the limits'));
- expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
- expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
- expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
- })
-})
+ expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument();
+ expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument();
+ expect(screen.getByText('What "Signed" means')).toBeInTheDocument();
+ });
+});
// FE-W5PLG-001 to FE-W5PLG-014 — toolbar filters and sorting, the error-log and
// allowed-hosts dialogs, and the drag-to-install overlay.
describe('AdminPluginsPanel toolbar and dialogs', () => {
- const row = (over: Record = {}) => plugin({
- id: 'a-widget', name: 'Alpha Widget', description: 'a widget', type: 'widget',
- version: '1.0.0', status: 'active', enabled: 1, operatorEgress: false,
- ...over,
- })
+ const row = (over: Record = {}) =>
+ plugin({
+ id: 'a-widget',
+ name: 'Alpha Widget',
+ description: 'a widget',
+ type: 'widget',
+ version: '1.0.0',
+ status: 'active',
+ enabled: 1,
+ operatorEgress: false,
+ ...over,
+ });
function mockPanel(rows: Record[], registry: unknown = []) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: rows })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)),
- )
+ http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry))
+ );
}
const three = () => [
row(),
- row({ id: 'b-integration', name: 'Beta Bridge', description: 'a bridge', type: 'integration', enabled: 0, status: 'inactive' }),
- row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
- ]
+ row({
+ id: 'b-integration',
+ name: 'Beta Bridge',
+ description: 'a bridge',
+ type: 'integration',
+ enabled: 0,
+ status: 'inactive',
+ }),
+ row({
+ id: 'c-page',
+ name: 'Gamma Page',
+ description: 'a page',
+ type: 'page',
+ enabled: 1,
+ status: 'error',
+ last_error: 'boom',
+ }),
+ ];
const pickFilter = async (user: ReturnType, filter: RegExp, option: RegExp) => {
- await user.click(screen.getByTitle(filter))
- await user.click(screen.getByRole('button', { name: option }))
- }
+ await user.click(screen.getByTitle(filter));
+ await user.click(screen.getByRole('button', { name: option }));
+ };
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
- const user = userEvent.setup()
- mockPanel(three())
- render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel(three());
+ render( );
+ await screen.findByText('Alpha Widget');
- await user.type(screen.getByPlaceholderText(/search/i), 'bridge')
+ await user.type(screen.getByPlaceholderText(/search/i), 'bridge');
- await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
- expect(screen.getByText('Beta Bridge')).toBeInTheDocument()
- })
+ await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument());
+ expect(screen.getByText('Beta Bridge')).toBeInTheDocument();
+ });
it('FE-W5PLG-002: a search with no hits shows the no-match state', async () => {
- const user = userEvent.setup()
- mockPanel(three())
- render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel(three());
+ render( );
+ await screen.findByText('Alpha Widget');
- await user.type(screen.getByPlaceholderText(/search/i), 'zzzz')
+ await user.type(screen.getByPlaceholderText(/search/i), 'zzzz');
- expect(await screen.findByText(/no.*match/i)).toBeInTheDocument()
- })
+ expect(await screen.findByText(/no.*match/i)).toBeInTheDocument();
+ });
it('FE-W5PLG-003: the type filter keeps only plugins of that type', async () => {
- const user = userEvent.setup()
- mockPanel(three())
- render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel(three());
+ render( );
+ await screen.findByText('Alpha Widget');
- await pickFilter(user, /^Type:/, /^Widget$/)
+ await pickFilter(user, /^Type:/, /^Widget$/);
- await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
- expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
- })
+ await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument());
+ expect(screen.getByText('Alpha Widget')).toBeInTheDocument();
+ });
it('FE-W5PLG-004: the status filter separates active, off and errored plugins', async () => {
- const user = userEvent.setup()
- mockPanel(three())
- render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel(three());
+ render( );
+ await screen.findByText('Alpha Widget');
- await pickFilter(user, /^Status:/, /^Active$/)
- await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
- expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
+ await pickFilter(user, /^Status:/, /^Active$/);
+ await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument());
+ expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument();
- await pickFilter(user, /^Status:/, /^Off$/)
- await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
- expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
+ await pickFilter(user, /^Status:/, /^Off$/);
+ await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument());
+ expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument();
- await pickFilter(user, /^Status:/, /^Error$/)
- await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
- expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
- })
+ await pickFilter(user, /^Status:/, /^Error$/);
+ await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument());
+ expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument();
+ });
it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => {
- const user = userEvent.setup()
- mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
- render( )
- await screen.findByText('Alpha Widget')
- await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
+ const user = userEvent.setup();
+ mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }]);
+ render( );
+ await screen.findByText('Alpha Widget');
+ await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument());
- await pickFilter(user, /^Sort:/, /^Updates first$/)
+ await pickFilter(user, /^Sort:/, /^Updates first$/);
await waitFor(() => {
- const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
- expect(names[0]).toBe('Gamma Page')
- })
+ const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map((e) => e.textContent);
+ expect(names[0]).toBe('Gamma Page');
+ });
- await pickFilter(user, /^Status:/, /^Update available$/)
- await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
- expect(screen.getByText('Gamma Page')).toBeInTheDocument()
- })
+ await pickFilter(user, /^Status:/, /^Update available$/);
+ await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument());
+ expect(screen.getByText('Gamma Page')).toBeInTheDocument();
+ });
it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => {
- const user = userEvent.setup()
- mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
- render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }]);
+ render( );
+ await screen.findByText('Alpha Widget');
- await pickFilter(user, /^Sort:/, /^Updates first$/)
- expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
+ await pickFilter(user, /^Sort:/, /^Updates first$/);
+ expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument();
- await user.click(screen.getByRole('tab', { name: /Discover/ }))
- await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
- })
+ await user.click(screen.getByRole('tab', { name: /Discover/ }));
+ await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument());
+ });
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup();
const reg = [
- { id: 'r-old', name: 'Old Tool', author: 'a', description: 'd', type: 'widget', downloadCount: 10, reviewedAt: '2024-01-01T00:00:00Z' },
- { id: 'r-new', name: 'New Tool', author: 'b', description: 'd', type: 'widget', downloadCount: 999, reviewedAt: '2026-01-01T00:00:00Z' },
- ]
- mockPanel(three(), reg)
- render( )
- await screen.findByText('Alpha Widget')
- await user.click(screen.getByRole('tab', { name: /Discover/ }))
- await screen.findByText('Old Tool')
+ {
+ id: 'r-old',
+ name: 'Old Tool',
+ author: 'a',
+ description: 'd',
+ type: 'widget',
+ downloadCount: 10,
+ reviewedAt: '2024-01-01T00:00:00Z',
+ },
+ {
+ id: 'r-new',
+ name: 'New Tool',
+ author: 'b',
+ description: 'd',
+ type: 'widget',
+ downloadCount: 999,
+ reviewedAt: '2026-01-01T00:00:00Z',
+ },
+ ];
+ mockPanel(three(), reg);
+ render( );
+ await screen.findByText('Alpha Widget');
+ await user.click(screen.getByRole('tab', { name: /Discover/ }));
+ await screen.findByText('Old Tool');
- await pickFilter(user, /^Sort:/, /^Most downloads$/)
+ await pickFilter(user, /^Sort:/, /^Most downloads$/);
await waitFor(() => {
- const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
- expect(names[0]).toBe('New Tool')
- })
+ const names = screen.getAllByText(/^(Old|New) Tool$/).map((e) => e.textContent);
+ expect(names[0]).toBe('New Tool');
+ });
- await pickFilter(user, /^Sort:/, /^Recently updated$/)
+ await pickFilter(user, /^Sort:/, /^Recently updated$/);
await waitFor(() => {
- const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
- expect(names[0]).toBe('New Tool')
- })
+ const names = screen.getAllByText(/^(Old|New) Tool$/).map((e) => e.textContent);
+ expect(names[0]).toBe('New Tool');
+ });
- await pickFilter(user, /^Sort:/, /^Name$/)
+ await pickFilter(user, /^Sort:/, /^Name$/);
await waitFor(() => {
- const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
- expect(names[0]).toBe('New Tool')
- })
- })
+ const names = screen.getAllByText(/^(Old|New) Tool$/).map((e) => e.textContent);
+ expect(names[0]).toBe('New Tool');
+ });
+ });
it('FE-W5PLG-008: the registry search and type filter narrow the discover grid', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup();
const reg = [
{ id: 'r-a', name: 'Mapper', author: 'ann', description: 'maps things', type: 'widget' },
{ id: 'r-b', name: 'Poster', author: 'bob', description: 'posts things', type: 'page' },
- ]
- mockPanel(three(), reg)
- render( )
- await screen.findByText('Alpha Widget')
- await user.click(screen.getByRole('tab', { name: /Discover/ }))
- await screen.findByText('Mapper')
+ ];
+ mockPanel(three(), reg);
+ render( );
+ await screen.findByText('Alpha Widget');
+ await user.click(screen.getByRole('tab', { name: /Discover/ }));
+ await screen.findByText('Mapper');
- await pickFilter(user, /^Type:/, /^Page$/)
- await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
- expect(screen.getByText('Poster')).toBeInTheDocument()
+ await pickFilter(user, /^Type:/, /^Page$/);
+ await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument());
+ expect(screen.getByText('Poster')).toBeInTheDocument();
- await user.type(screen.getByPlaceholderText(/search/i), 'ann')
- await waitFor(() => expect(screen.queryByText('Poster')).not.toBeInTheDocument())
- })
+ await user.type(screen.getByPlaceholderText(/search/i), 'ann');
+ await waitFor(() => expect(screen.queryByText('Poster')).not.toBeInTheDocument());
+ });
it('FE-W5PLG-009: the error log lists both levels and closes again', async () => {
- const user = userEvent.setup()
- mockPanel([row()])
+ const user = userEvent.setup();
+ mockPanel([row()]);
server.use(
http.get('*/api/admin/plugins/a-widget/errors', () =>
- HttpResponse.json({ errors: [
- { level: 'error', ts: '2026-01-01 10:00', message: 'exploded' },
- { level: 'warn', ts: '2026-01-01 10:01', message: 'wobbled' },
- ] }),
- ),
- )
- render( )
- await screen.findByText('Alpha Widget')
+ HttpResponse.json({
+ errors: [
+ { level: 'error', ts: '2026-01-01 10:00', message: 'exploded' },
+ { level: 'warn', ts: '2026-01-01 10:01', message: 'wobbled' },
+ ],
+ })
+ )
+ );
+ render( );
+ await screen.findByText('Alpha Widget');
- await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
- await user.click(screen.getByRole('button', { name: /view error log/i }))
+ await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'));
+ await user.click(screen.getByRole('button', { name: /view error log/i }));
- expect(await screen.findByText('exploded')).toBeInTheDocument()
- expect(screen.getByText('wobbled')).toBeInTheDocument()
- expect(screen.getByText('error')).toBeInTheDocument()
- expect(screen.getByText('warn')).toBeInTheDocument()
+ expect(await screen.findByText('exploded')).toBeInTheDocument();
+ expect(screen.getByText('wobbled')).toBeInTheDocument();
+ expect(screen.getByText('error')).toBeInTheDocument();
+ expect(screen.getByText('warn')).toBeInTheDocument();
- const dialog = screen.getByText('exploded').closest('.fixed') as HTMLElement
- await user.click(within(dialog).getAllByRole('button')[0])
- await waitFor(() => expect(screen.queryByText('exploded')).not.toBeInTheDocument())
- })
+ const dialog = screen.getByText('exploded').closest('.fixed') as HTMLElement;
+ await user.click(within(dialog).getAllByRole('button')[0]);
+ await waitFor(() => expect(screen.queryByText('exploded')).not.toBeInTheDocument());
+ });
it('FE-W5PLG-010: an empty error log says so and the backdrop closes it', async () => {
- const user = userEvent.setup()
- mockPanel([row()])
- server.use(
- http.get('*/api/admin/plugins/a-widget/errors', () => HttpResponse.json({ errors: [] })),
- )
- render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel([row()]);
+ server.use(http.get('*/api/admin/plugins/a-widget/errors', () => HttpResponse.json({ errors: [] })));
+ render( );
+ await screen.findByText('Alpha Widget');
- await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
- await user.click(screen.getByRole('button', { name: /view error log/i }))
+ await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'));
+ await user.click(screen.getByRole('button', { name: /view error log/i }));
- const empty = await screen.findByText(/no errors/i)
- clickBackdrop(empty.closest('.fixed') as HTMLElement)
- await waitFor(() => expect(screen.queryByText(/no errors/i)).not.toBeInTheDocument())
- })
+ const empty = await screen.findByText(/no errors/i);
+ clickBackdrop(empty.closest('.fixed') as HTMLElement);
+ await waitFor(() => expect(screen.queryByText(/no errors/i)).not.toBeInTheDocument());
+ });
it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup();
// Declares operatorEgress (so the menu item shows), but the runtime lookup
// disagrees — the dialog's unsupported notice is the truth-teller.
- mockPanel([row({ operatorEgress: true })])
+ mockPanel([row({ operatorEgress: true })]);
server.use(
- http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })),
- )
- render( )
- await screen.findByText('Alpha Widget')
+ http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] }))
+ );
+ render( );
+ await screen.findByText('Alpha Widget');
- await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
- await user.click(screen.getByRole('button', { name: /allowed hosts/i }))
+ await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'));
+ await user.click(screen.getByRole('button', { name: /allowed hosts/i }));
- expect(await screen.findByText(/does not|not supported|no outbound/i)).toBeInTheDocument()
- })
+ expect(await screen.findByText(/does not|not supported|no outbound/i)).toBeInTheDocument();
+ });
it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => {
- const user = userEvent.setup()
- mockPanel([row({ operatorEgress: true })])
+ const user = userEvent.setup();
+ mockPanel([row({ operatorEgress: true })]);
server.use(
- http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
- )
- render( )
- await screen.findByText('Alpha Widget')
+ http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
+ );
+ render( );
+ await screen.findByText('Alpha Widget');
- await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
- await user.click(screen.getByRole('button', { name: /allowed hosts/i }))
+ await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'));
+ await user.click(screen.getByRole('button', { name: /allowed hosts/i }));
- expect(await screen.findByText(/a-widget —/)).toBeInTheDocument()
- })
+ expect(await screen.findByText(/a-widget —/)).toBeInTheDocument();
+ });
it('FE-W5PLG-013: dragging a file over the panel arms the drop overlay', async () => {
- mockPanel([row()])
- const { container } = render( )
- await screen.findByText('Alpha Widget')
- const panel = container.firstElementChild as HTMLElement
+ mockPanel([row()]);
+ const { container } = render( );
+ await screen.findByText('Alpha Widget');
+ const panel = container.firstElementChild as HTMLElement;
- fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'] } })
- expect(await screen.findByText(/drop.*to (upload|install)/i)).toBeInTheDocument()
+ fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'] } });
+ expect(await screen.findByText(/drop.*to (upload|install)/i)).toBeInTheDocument();
// nested enter/leave pairs must not disarm it early
- fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'] } })
- fireEvent.dragLeave(panel)
- expect(screen.getByText(/drop.*to (upload|install)/i)).toBeInTheDocument()
+ fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'] } });
+ fireEvent.dragLeave(panel);
+ expect(screen.getByText(/drop.*to (upload|install)/i)).toBeInTheDocument();
- fireEvent.dragLeave(panel)
- await waitFor(() => expect(screen.queryByText(/drop.*to (upload|install)/i)).not.toBeInTheDocument())
- })
+ fireEvent.dragLeave(panel);
+ await waitFor(() => expect(screen.queryByText(/drop.*to (upload|install)/i)).not.toBeInTheDocument());
+ });
it('FE-W5PLG-014: dropping a plugin archive uploads it', async () => {
- let uploaded = false
- mockPanel([row()])
+ let uploaded = false;
+ mockPanel([row()]);
server.use(
- http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'a-widget' }) }),
- )
+ http.post('*/api/admin/plugins/upload', () => {
+ uploaded = true;
+ return HttpResponse.json({ id: 'a-widget' });
+ })
+ );
const { container } = render(
<>
- >,
- )
- await screen.findByText('Alpha Widget')
- const panel = container.firstElementChild as HTMLElement
+ >
+ );
+ await screen.findByText('Alpha Widget');
+ const panel = container.firstElementChild as HTMLElement;
- const file = new File(['zip'], 'plugin.zip', { type: 'application/zip' })
- fireEvent.drop(panel, { dataTransfer: { files: [file] } })
+ const file = new File(['zip'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.drop(panel, { dataTransfer: { files: [file] } });
- await waitFor(() => expect(uploaded).toBe(true))
- })
+ await waitFor(() => expect(uploaded).toBe(true));
+ });
it('FE-W5PLG-015: the click-away layer closes an open filter menu', async () => {
- const user = userEvent.setup()
- mockPanel([row()])
- const { container } = render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel([row()]);
+ const { container } = render( );
+ await screen.findByText('Alpha Widget');
- await user.click(screen.getByTitle(/^Type:/))
- expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
+ await user.click(screen.getByTitle(/^Type:/));
+ expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument();
- const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
- fireEvent.click(layer)
- await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
- })
-})
+ const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement;
+ fireEvent.click(layer);
+ await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument());
+ });
+});
// FE-W5PLG-016 to FE-W5PLG-023 — registry cards, the detail modal and the
// dependency chips of an installed row.
describe('AdminPluginsPanel registry cards and dependency chips', () => {
- const row = (over: Record = {}) => plugin({
- id: 'a-widget', name: 'Alpha Widget', description: 'a widget', type: 'widget',
- version: '1.0.0', status: 'active', enabled: 1, operatorEgress: false,
- permissions: JSON.stringify([]),
- ...over,
- })
+ const row = (over: Record = {}) =>
+ plugin({
+ id: 'a-widget',
+ name: 'Alpha Widget',
+ description: 'a widget',
+ type: 'widget',
+ version: '1.0.0',
+ status: 'active',
+ enabled: 1,
+ operatorEgress: false,
+ permissions: JSON.stringify([]),
+ ...over,
+ });
const regItem = (over: Record = {}) => ({
- id: 'r-a', name: 'Mapper', author: 'ann', description: 'maps things', type: 'widget',
- latest: '2.0.0', repo: 'ann/mapper', signed: true, ...over,
- })
+ id: 'r-a',
+ name: 'Mapper',
+ author: 'ann',
+ description: 'maps things',
+ type: 'widget',
+ latest: '2.0.0',
+ repo: 'ann/mapper',
+ signed: true,
+ ...over,
+ });
function mockPanel(rows: Record[], registry: unknown = []) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: rows })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)),
- )
+ http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry))
+ );
}
const openDiscover = async (user: ReturnType) => {
- await user.click(screen.getByRole('tab', { name: /Discover/ }))
- }
+ await user.click(screen.getByRole('tab', { name: /Discover/ }));
+ };
it('FE-W5PLG-016: leaving Discover snaps the downloads sort back to name', async () => {
- const user = userEvent.setup()
- mockPanel([row()], [regItem({ downloadCount: 5 })])
- render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel([row()], [regItem({ downloadCount: 5 })]);
+ render( );
+ await screen.findByText('Alpha Widget');
- await openDiscover(user)
- await user.click(screen.getByTitle(/^Sort:/))
- await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
- expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
+ await openDiscover(user);
+ await user.click(screen.getByTitle(/^Sort:/));
+ await user.click(screen.getByRole('button', { name: /^Most downloads$/ }));
+ expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument();
- await user.click(screen.getByRole('tab', { name: /Installed/ }))
- await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
- })
+ await user.click(screen.getByRole('tab', { name: /Installed/ }));
+ await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument());
+ });
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
- const user = userEvent.setup()
- mockPanel([row()], [
- regItem({ id: 'r-old', name: 'Older Fit', compatible: false, latestCompatible: '1.4.0', trek: '>=4', hostVersion: '3.5.0' }),
- regItem({ id: 'r-dead', name: 'No Fit', compatible: false, latestCompatible: null, trek: null }),
- ])
- render( )
- await screen.findByText('Alpha Widget')
- await openDiscover(user)
+ const user = userEvent.setup();
+ mockPanel(
+ [row()],
+ [
+ regItem({
+ id: 'r-old',
+ name: 'Older Fit',
+ compatible: false,
+ latestCompatible: '1.4.0',
+ trek: '>=4',
+ hostVersion: '3.5.0',
+ }),
+ regItem({ id: 'r-dead', name: 'No Fit', compatible: false, latestCompatible: null, trek: null }),
+ ]
+ );
+ render( );
+ await screen.findByText('Alpha Widget');
+ await openDiscover(user);
- const offer = await screen.findByRole('button', { name: /^Install 1\.4\.0$/ })
- expect(offer).not.toBeDisabled()
- expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
+ const offer = await screen.findByRole('button', { name: /^Install 1\.4\.0$/ });
+ expect(offer).not.toBeDisabled();
+ expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'));
- const dead = screen.getByRole('button', { name: /^Incompatible$/ })
- expect(dead).toBeDisabled()
- expect(dead.getAttribute('title')).toBeTruthy()
- })
+ const dead = screen.getByRole('button', { name: /^Incompatible$/ });
+ expect(dead).toBeDisabled();
+ expect(dead.getAttribute('title')).toBeTruthy();
+ });
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
- const user = userEvent.setup()
- mockPanel([row({ id: 'r-a', name: 'Mapper' })], [regItem({ reviewedAt: '2026-01-01T00:00:00Z', downloadCount: 1234 })])
- render( )
- await screen.findByText('Mapper')
- await openDiscover(user)
+ const user = userEvent.setup();
+ mockPanel(
+ [row({ id: 'r-a', name: 'Mapper' })],
+ [regItem({ reviewedAt: '2026-01-01T00:00:00Z', downloadCount: 1234 })]
+ );
+ render( );
+ await screen.findByText('Mapper');
+ await openDiscover(user);
- expect(await screen.findByText('1.2k')).toBeInTheDocument()
- const installBtn = screen.getAllByRole('button', { name: /^Installed$/ })[0]
- expect(installBtn).toBeDisabled()
- })
+ expect(await screen.findByText('1.2k')).toBeInTheDocument();
+ const installBtn = screen.getAllByRole('button', { name: /^Installed$/ })[0];
+ expect(installBtn).toBeDisabled();
+ });
it('FE-W5PLG-019: a registry card opens its detail modal by click and by keyboard', async () => {
- const user = userEvent.setup()
- mockPanel([row()], [regItem({ reviewedAt: '2026-01-01T00:00:00Z', homepage: 'https://mapper.example' })])
+ const user = userEvent.setup();
+ mockPanel([row()], [regItem({ reviewedAt: '2026-01-01T00:00:00Z', homepage: 'https://mapper.example' })]);
server.use(
http.get('*/api/admin/plugins/registry/r-a', () =>
- HttpResponse.json({ size: 4096, manifest: { icon: 'Map', permissions: ['db:read:trips'], egress: ['api.mapper.example'], operatorEgress: false, settings: [] } })),
- )
- render( )
- await screen.findByText('Alpha Widget')
- await openDiscover(user)
+ HttpResponse.json({
+ size: 4096,
+ manifest: {
+ icon: 'Map',
+ permissions: ['db:read:trips'],
+ egress: ['api.mapper.example'],
+ operatorEgress: false,
+ settings: [],
+ },
+ })
+ )
+ );
+ render( );
+ await screen.findByText('Alpha Widget');
+ await openDiscover(user);
- const card = screen.getByText('Mapper').closest('[role="button"]') as HTMLElement
- fireEvent.keyDown(card, { key: 'Enter' })
- expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument()
+ const card = screen.getByText('Mapper').closest('[role="button"]') as HTMLElement;
+ fireEvent.keyDown(card, { key: 'Enter' });
+ expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument();
- clickBackdrop(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement)
- await waitFor(() => expect(screen.queryByRole('heading', { name: 'Mapper' })).not.toBeInTheDocument())
+ clickBackdrop(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement);
+ await waitFor(() => expect(screen.queryByRole('heading', { name: 'Mapper' })).not.toBeInTheDocument());
- fireEvent.keyDown(card, { key: ' ' })
- expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument()
- })
+ fireEvent.keyDown(card, { key: ' ' });
+ expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument();
+ });
it('FE-W5PLG-020: a failing detail fetch still shows the modal shell', async () => {
- const user = userEvent.setup()
- mockPanel([row()], [regItem()])
+ const user = userEvent.setup();
+ mockPanel([row()], [regItem()]);
server.use(
- http.get('*/api/admin/plugins/registry/r-a', () => HttpResponse.json({ error: 'gone' }, { status: 404 })),
- )
- render( )
- await screen.findByText('Alpha Widget')
- await openDiscover(user)
+ http.get('*/api/admin/plugins/registry/r-a', () => HttpResponse.json({ error: 'gone' }, { status: 404 }))
+ );
+ render( );
+ await screen.findByText('Alpha Widget');
+ await openDiscover(user);
- await user.click(screen.getByText('Mapper'))
+ await user.click(screen.getByText('Mapper'));
- expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument()
- expect(screen.getByText(/ann · v2\.0\.0/)).toBeInTheDocument()
- })
+ expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument();
+ expect(screen.getByText(/ann · v2\.0\.0/)).toBeInTheDocument();
+ });
it('FE-W5PLG-021: dependency chips flag a disabled addon and a missing plugin', async () => {
- mockPanel([row({
- dependencies: { requiredAddons: ['budget', 'vacay'], pluginDependencies: [{ id: 'dep-a', version: '^1' }, { id: 'dep-b', version: '^2' }] },
- dependencyStatus: 'blocked',
- dependencyIssues: { disabledAddons: ['vacay'], missing: [{ id: 'dep-a' }], versionMismatch: [{ id: 'dep-b' }] },
- })])
- render( )
+ mockPanel([
+ row({
+ dependencies: {
+ requiredAddons: ['budget', 'vacay'],
+ pluginDependencies: [
+ { id: 'dep-a', version: '^1' },
+ { id: 'dep-b', version: '^2' },
+ ],
+ },
+ dependencyStatus: 'blocked',
+ dependencyIssues: { disabledAddons: ['vacay'], missing: [{ id: 'dep-a' }], versionMismatch: [{ id: 'dep-b' }] },
+ }),
+ ]);
+ render( );
- await screen.findByText('Alpha Widget')
- expect(screen.getByText(/budget/)).toBeInTheDocument()
- expect(screen.getByText(/vacay/)).toBeInTheDocument()
- expect(screen.getByText(/dep-a/)).toBeInTheDocument()
- expect(screen.getByText(/dep-b/)).toBeInTheDocument()
- })
+ await screen.findByText('Alpha Widget');
+ expect(screen.getByText(/budget/)).toBeInTheDocument();
+ expect(screen.getByText(/vacay/)).toBeInTheDocument();
+ expect(screen.getByText(/dep-a/)).toBeInTheDocument();
+ expect(screen.getByText(/dep-b/)).toBeInTheDocument();
+ });
it('FE-W5PLG-022: a plugin whose TREK range is unknown still gets an incompatibility chip', async () => {
- mockPanel([row({ dependencyStatus: 'hostIncompatible', trekRange: null, hostVersion: null })])
- render( )
+ mockPanel([row({ dependencyStatus: 'hostIncompatible', trekRange: null, hostVersion: null })]);
+ render( );
- await screen.findByText('Alpha Widget')
- expect(screen.getByText(/does not say which trek/i)).toBeInTheDocument()
- })
+ await screen.findByText('Alpha Widget');
+ expect(screen.getByText(/does not say which trek/i)).toBeInTheDocument();
+ });
it('FE-W5PLG-023: a registry-sourced row links to its repository and issue tracker', async () => {
- const user = userEvent.setup()
- mockPanel([row({ source_repo: 'ann/mapper', reviewed_at: '2026-01-01T00:00:00Z', version: null, description: null, status: 'weird' })])
- render( )
- await screen.findByText('Alpha Widget')
+ const user = userEvent.setup();
+ mockPanel([
+ row({
+ source_repo: 'ann/mapper',
+ reviewed_at: '2026-01-01T00:00:00Z',
+ version: null,
+ description: null,
+ status: 'weird',
+ }),
+ ]);
+ render( );
+ await screen.findByText('Alpha Widget');
- await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'))
- const repo = screen.getByRole('link', { name: /source|repository/i })
- expect(repo).toHaveAttribute('href', 'https://github.com/ann/mapper')
- expect(screen.getByRole('link', { name: /issue/i })).toHaveAttribute('href', 'https://github.com/ann/mapper/issues')
+ await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget'));
+ const repo = screen.getByRole('link', { name: /source|repository/i });
+ expect(repo).toHaveAttribute('href', 'https://github.com/ann/mapper');
+ expect(screen.getByRole('link', { name: /issue/i })).toHaveAttribute(
+ 'href',
+ 'https://github.com/ann/mapper/issues'
+ );
- await user.click(repo)
- await waitFor(() => expect(screen.queryByRole('link', { name: /issue/i })).not.toBeInTheDocument())
- })
-})
+ await user.click(repo);
+ await waitFor(() => expect(screen.queryByRole('link', { name: /issue/i })).not.toBeInTheDocument());
+ });
+});
// FE-W5PLG-024 to FE-W5PLG-028 — the signature dialog's fingerprint rendering
// and the remaining dialog dismissals.
describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
- const blocked = (over: Record = {}) => plugin({
- source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa',
- updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
- ...over,
- })
+ const blocked = (over: Record = {}) =>
+ plugin({
+ source_repo: 'acme/gotify',
+ signed: true,
+ keyFingerprint: 'OLDKEYaa…aaaaaaaa',
+ updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
+ ...over,
+ });
function mockPanel(row: Record, registry: unknown[] = []) {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [row] })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)),
- )
+ http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry))
+ );
}
it('FE-W5PLG-024: a long new key is shown head…tail next to the pinned one', async () => {
- const key = 'untrusted comment: minisign key\nRWQf6LRCGA9iA1234567890ABCDEFGH'
- mockPanel(blocked(), [{ id: 'trek-gotify', name: 'Gotify', author: 'acme', description: 'd', type: 'integration', latest: '2.0.0', authorPublicKey: key }])
- render( )
- await screen.findByText('Gotify')
+ const key = 'untrusted comment: minisign key\nRWQf6LRCGA9iA1234567890ABCDEFGH';
+ mockPanel(blocked(), [
+ {
+ id: 'trek-gotify',
+ name: 'Gotify',
+ author: 'acme',
+ description: 'd',
+ type: 'integration',
+ latest: '2.0.0',
+ authorPublicKey: key,
+ },
+ ]);
+ render( );
+ await screen.findByText('Gotify');
- fireEvent.click(await screen.findByRole('button', { name: /review/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /review/i }));
- expect(await screen.findByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument()
- expect(screen.getByText('RWQf6LRC…ABCDEFGH')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
- })
+ expect(await screen.findByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument();
+ expect(screen.getByText('RWQf6LRC…ABCDEFGH')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument();
+ });
it('FE-W5PLG-025: a short key is shown in full and a missing pinned key falls back to a dash', async () => {
mockPanel(blocked({ keyFingerprint: null }), [
- { id: 'trek-gotify', name: 'Gotify', author: 'acme', description: 'd', type: 'integration', latest: '2.0.0', authorPublicKey: 'SHORTKEY' },
- ])
- render( )
- await screen.findByText('Gotify')
+ {
+ id: 'trek-gotify',
+ name: 'Gotify',
+ author: 'acme',
+ description: 'd',
+ type: 'integration',
+ latest: '2.0.0',
+ authorPublicKey: 'SHORTKEY',
+ },
+ ]);
+ render( );
+ await screen.findByText('Gotify');
- fireEvent.click(await screen.findByRole('button', { name: /review/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /review/i }));
- expect(await screen.findByText('SHORTKEY')).toBeInTheDocument()
- expect(screen.getByText('—')).toBeInTheDocument()
- })
+ expect(await screen.findByText('SHORTKEY')).toBeInTheDocument();
+ expect(screen.getByText('—')).toBeInTheDocument();
+ });
it('FE-W5PLG-026: without a registry entry there is no key to offer and no override', async () => {
- mockPanel(blocked())
- render( )
+ mockPanel(blocked());
+ render( );
- fireEvent.click(await screen.findByRole('button', { name: /review/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /review/i }));
- expect(await screen.findByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument()
- expect(screen.getByText('—')).toBeInTheDocument()
- expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
- })
+ expect(await screen.findByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument();
+ expect(screen.getByText('—')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument();
+ });
it('FE-W5PLG-027: an incomplete signature gets its own explanation and closes on the backdrop', async () => {
- mockPanel(blocked({ updateBlock: { code: 'SIGNATURE_INCOMPLETE', detail: 'only half signed', version: '2.0.0' } }))
- render( )
+ mockPanel(blocked({ updateBlock: { code: 'SIGNATURE_INCOMPLETE', detail: 'only half signed', version: '2.0.0' } }));
+ render( );
- fireEvent.click(await screen.findByRole('button', { name: /review/i }))
- const detail = await screen.findByText('only half signed')
+ fireEvent.click(await screen.findByRole('button', { name: /review/i }));
+ const detail = await screen.findByText('only half signed');
- clickBackdrop(detail.closest('.fixed') as HTMLElement)
- await waitFor(() => expect(screen.queryByText('only half signed')).not.toBeInTheDocument())
- })
+ clickBackdrop(detail.closest('.fixed') as HTMLElement);
+ await waitFor(() => expect(screen.queryByText('only half signed')).not.toBeInTheDocument());
+ });
it('FE-W5PLG-028: the allowed-hosts dialog closes from its header button and its backdrop', async () => {
- const user = userEvent.setup()
- mockPanel(plugin({ egressHostCount: 1 }))
+ const user = userEvent.setup();
+ mockPanel(plugin({ egressHostCount: 1 }));
server.use(
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
- HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })),
- )
- render( )
+ HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })
+ )
+ );
+ render( );
- await user.click(await screen.findByRole('button', { name: /1 allowed host/i }))
- const host = await screen.findByText('gotify.mydomain.com')
- const dialog = host.closest('.fixed') as HTMLElement
+ await user.click(await screen.findByRole('button', { name: /1 allowed host/i }));
+ const host = await screen.findByText('gotify.mydomain.com');
+ const dialog = host.closest('.fixed') as HTMLElement;
- await user.click(within(dialog).getAllByRole('button')[0])
- await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument())
+ await user.click(within(dialog).getAllByRole('button')[0]);
+ await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: /1 allowed host/i }))
- const reopened = (await screen.findByText('gotify.mydomain.com')).closest('.fixed') as HTMLElement
- clickBackdrop(reopened)
- await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument())
- })
+ await user.click(screen.getByRole('button', { name: /1 allowed host/i }));
+ const reopened = (await screen.findByText('gotify.mydomain.com')).closest('.fixed') as HTMLElement;
+ clickBackdrop(reopened);
+ await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument());
+ });
it('FE-W5PLG-029: a drop without a file is ignored', async () => {
- let uploaded = false
- mockPanel(plugin())
+ let uploaded = false;
+ mockPanel(plugin());
server.use(
- http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'x' }) }),
- )
- const { container } = render( )
- await screen.findByText('Gotify')
+ http.post('*/api/admin/plugins/upload', () => {
+ uploaded = true;
+ return HttpResponse.json({ id: 'x' });
+ })
+ );
+ const { container } = render( );
+ await screen.findByText('Gotify');
- fireEvent.drop(container.firstElementChild as HTMLElement, { dataTransfer: { files: [] } })
+ fireEvent.drop(container.firstElementChild as HTMLElement, { dataTransfer: { files: [] } });
- await waitFor(() => expect(uploaded).toBe(false))
- })
-})
+ await waitFor(() => expect(uploaded).toBe(false));
+ });
+});
// FE-W5PLG-030 — Update all drives the same single-slot busy flag as a per-row update,
// so the requests have to go out one after another.
describe('AdminPluginsPanel — update all', () => {
- const updatableRow = (over: Record = {}) => plugin({
- id: 'a-widget', name: 'Alpha Widget', type: 'widget', version: '1.0.0', operatorEgress: false, ...over,
- })
+ const updatableRow = (over: Record = {}) =>
+ plugin({
+ id: 'a-widget',
+ name: 'Alpha Widget',
+ type: 'widget',
+ version: '1.0.0',
+ operatorEgress: false,
+ ...over,
+ });
it('FE-W5PLG-030: Update all sends the second update only after the first came back', async () => {
- const started: string[] = []
- let release = () => {}
- const gate = new Promise(resolve => { release = resolve })
+ const started: string[] = [];
+ let release = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
server.use(
- http.get('*/api/admin/plugins', () => HttpResponse.json({
- enabled: true, devLink: false,
- plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
- })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json([
- { id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
- { id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
- ])),
+ http.get('*/api/admin/plugins', () =>
+ HttpResponse.json({
+ enabled: true,
+ devLink: false,
+ plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
+ })
+ ),
+ http.get('*/api/admin/plugins/registry', () =>
+ HttpResponse.json([
+ {
+ id: 'a-widget',
+ name: 'Alpha Widget',
+ author: 'acme',
+ description: 'd',
+ type: 'widget',
+ latest: '2.0.0',
+ latestCompatible: '2.0.0',
+ },
+ {
+ id: 'b-widget',
+ name: 'Beta Widget',
+ author: 'acme',
+ description: 'd',
+ type: 'widget',
+ latest: '2.0.0',
+ latestCompatible: '2.0.0',
+ },
+ ])
+ ),
http.post('*/api/admin/plugins/:id/update', async ({ params }) => {
- started.push(String(params.id))
- if (started.length === 1) await gate
- return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
- }),
- )
- render( )
+ started.push(String(params.id));
+ if (started.length === 1) await gate;
+ return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
+ })
+ );
+ render( );
- fireEvent.click(await screen.findByRole('button', { name: /update all/i }))
+ fireEvent.click(await screen.findByRole('button', { name: /update all/i }));
- await waitFor(() => expect(started).toHaveLength(1))
+ await waitFor(() => expect(started).toHaveLength(1));
// Give a parallel dispatch every chance to show up before releasing the first.
- await new Promise(resolve => setTimeout(resolve, 20))
- expect(started).toEqual(['a-widget'])
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(started).toEqual(['a-widget']);
- release()
- await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget']))
- })
-})
+ release();
+ await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget']));
+ });
+});
/**
* The admin-owned `scope:'instance'` settings form. The fields are declared manifest
@@ -2271,269 +2810,335 @@ describe('AdminPluginsPanel — instance settings', () => {
const FIELDS = [
{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false },
{ key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true },
- ]
+ ];
async function openRowMenu(p: Record) {
- mockList(p)
- render(<> >)
- fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
+ mockList(p);
+ render(
+ <>
+
+
+ >
+ );
+ fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
}
- async function openSettings(config: Record = { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' }) {
- server.use(
- http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ fields: FIELDS, config })),
- )
- await openRowMenu(plugin({ instanceSettingsCount: 2 }))
- fireEvent.click(screen.getByText('Instance settings'))
- await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
+ async function openSettings(
+ config: Record = { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' }
+ ) {
+ server.use(http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ fields: FIELDS, config })));
+ await openRowMenu(plugin({ instanceSettingsCount: 2 }));
+ fireEvent.click(screen.getByText('Instance settings'));
+ await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), {
+ timeout: 5000,
+ });
}
it('FE-COMP-PLUGINS-CFG-001: a plugin with instance fields offers the menu item, opening the form', async () => {
- await openSettings()
+ await openSettings();
// Both declared fields render, the secret as a masked password input.
- expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
- expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password')
- })
+ expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password');
+ });
it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => {
- await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 }))
- expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
- expect(screen.queryByText('Instance settings')).not.toBeInTheDocument()
- })
+ await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 }));
+ expect(screen.getByText('View error log')).toBeInTheDocument(); // menu is open
+ expect(screen.queryByText('Instance settings')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-CFG-008: a plugin with instance ACTIONS but no fields still offers the menu item', async () => {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
- HttpResponse.json({ fields: [], config: {}, actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }] })),
- )
- await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 }))
- expect(screen.getByText('Instance settings')).toBeInTheDocument()
- fireEvent.click(screen.getByText('Instance settings'))
- expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument()
- })
+ HttpResponse.json({
+ fields: [],
+ config: {},
+ actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }],
+ })
+ )
+ );
+ await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 }));
+ expect(screen.getByText('Instance settings')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('Instance settings'));
+ expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => {
- let body: Record | null = null
+ let body: Record | null = null;
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
- body = await request.json() as Record
- return HttpResponse.json({ config: body, restarted: false })
- }),
- )
- await openSettings()
+ body = (await request.json()) as Record;
+ return HttpResponse.json({ config: body, restarted: false });
+ })
+ );
+ await openSettings();
- fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
- fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
+ fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), {
+ target: { value: 'https://new.example' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
- expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
+ expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument();
// The mask is a display artifact, not a value — sending it would overwrite the stored secret.
- expect(body).toEqual({ apiUrl: 'https://new.example' })
- })
+ expect(body).toEqual({ apiUrl: 'https://new.example' });
+ });
it('FE-COMP-PLUGINS-CFG-004: a save that restarted the running plugin says so', async () => {
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', () =>
- HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })),
- )
- await openSettings()
+ HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })
+ )
+ );
+ await openSettings();
- fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
+ fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
- expect(await screen.findByText('Settings saved — plugin restarted', {}, { timeout: 5000 })).toBeInTheDocument()
- })
+ expect(await screen.findByText('Settings saved — plugin restarted', {}, { timeout: 5000 })).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-CFG-005: renders every declared field type and saves the edited values', async () => {
const RICH = [
- { key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' },
+ {
+ key: 'mode',
+ label: 'Mode',
+ input_type: 'select',
+ options: [
+ { value: 'fast', label: 'Fast' },
+ { value: 'slow', label: 'Slow' },
+ ],
+ hint: 'Pick one',
+ },
{ key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in
{ key: 'retries', label: 'Retries', input_type: 'number', required: true },
- ]
- let body: Record | null = null
+ ];
+ let body: Record | null = null;
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
- HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })),
+ HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })
+ ),
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
- body = await request.json() as Record
- return HttpResponse.json({ config: body, restarted: false })
- }),
- )
- await openRowMenu(plugin({ instanceSettingsCount: 3 }))
- fireEvent.click(screen.getByText('Instance settings'))
+ body = (await request.json()) as Record;
+ return HttpResponse.json({ config: body, restarted: false });
+ })
+ );
+ await openRowMenu(plugin({ instanceSettingsCount: 3 }));
+ fireEvent.click(screen.getByText('Instance settings'));
// Stored values land in the right controls; an unset value renders empty.
- 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
+ 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.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 }))
+ 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 }));
- expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
- expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' })
- })
+ expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument();
+ expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' });
+ });
const ACTIONS = [
{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' },
{ key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' },
- ]
+ ];
async function openWithActions(p: Record = plugin({ instanceSettingsCount: 2 })) {
server.use(
http.get('*/api/admin/plugins/trek-gotify/config', () =>
- HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })),
- )
- await openRowMenu(p)
- fireEvent.click(screen.getByText('Instance settings'))
- await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
+ HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })
+ )
+ );
+ await openRowMenu(p);
+ fireEvent.click(screen.getByText('Instance settings'));
+ await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), {
+ timeout: 5000,
+ });
}
it('FE-COMP-PLUGINS-ACT-010: instance actions render below the fields and run against the admin route', async () => {
- let posted = ''
+ let posted = '';
server.use(
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
- posted = String(params.key)
- return HttpResponse.json({ ok: true, message: 'pong' })
- }),
- )
- await openWithActions()
- expect(screen.getByText('Drops every cached tile')).toBeInTheDocument() // hint
- fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
- expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument()
- expect(posted).toBe('ping')
+ posted = String(params.key);
+ return HttpResponse.json({ ok: true, message: 'pong' });
+ })
+ );
+ await openWithActions();
+ expect(screen.getByText('Drops every cached tile')).toBeInTheDocument(); // hint
+ fireEvent.click(screen.getByRole('button', { name: 'Ping server' }));
+ expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument();
+ expect(posted).toBe('ping');
// The dialog stays open — the admin may run another one.
- expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
- })
+ expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-ACT-011: a danger action asks first; confirming runs it', async () => {
- let posted = ''
+ let posted = '';
server.use(
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
- posted = String(params.key)
- return HttpResponse.json({ ok: false, message: 'cache locked' })
- }),
- )
- await openWithActions()
- fireEvent.click(screen.getByRole('button', { name: 'Purge cache' }))
- expect(await screen.findByText('Run this action?')).toBeInTheDocument()
- expect(posted).toBe('')
- fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }))
- expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument()
- expect(posted).toBe('purge')
- })
+ posted = String(params.key);
+ return HttpResponse.json({ ok: false, message: 'cache locked' });
+ })
+ );
+ await openWithActions();
+ fireEvent.click(screen.getByRole('button', { name: 'Purge cache' }));
+ expect(await screen.findByText('Run this action?')).toBeInTheDocument();
+ expect(posted).toBe('');
+ fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }));
+ expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument();
+ expect(posted).toBe('purge');
+ });
it('FE-COMP-PLUGINS-ACT-012: an inactive plugin shows the buttons disabled with the activation hint', async () => {
- await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 }))
- expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled()
- expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument()
- })
+ await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 }));
+ expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled();
+ expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-ACT-013: a dirty form is saved before the action runs', async () => {
- const calls: string[] = []
+ const calls: string[] = [];
server.use(
- http.put('*/api/admin/plugins/trek-gotify/config', () => { calls.push('save'); return HttpResponse.json({ config: {}, restarted: false }) }),
- http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => { calls.push('run'); return HttpResponse.json({ ok: true }) }),
- )
- await openWithActions()
- fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
- fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
- await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 })
- })
+ http.put('*/api/admin/plugins/trek-gotify/config', () => {
+ calls.push('save');
+ return HttpResponse.json({ config: {}, restarted: false });
+ }),
+ http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => {
+ calls.push('run');
+ return HttpResponse.json({ ok: true });
+ })
+ );
+ await openWithActions();
+ fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), {
+ target: { value: 'https://new.example' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Ping server' }));
+ await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 });
+ });
it('FE-COMP-PLUGINS-CFG-006: a failed config fetch is a toast, not a broken dialog', async () => {
server.use(
- http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
- )
- await openRowMenu(plugin({ instanceSettingsCount: 2 }))
- fireEvent.click(screen.getByText('Instance settings'))
+ http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
+ );
+ await openRowMenu(plugin({ instanceSettingsCount: 2 }));
+ fireEvent.click(screen.getByText('Instance settings'));
- expect(await screen.findByText('Error', {}, { timeout: 5000 })).toBeInTheDocument()
- })
+ expect(await screen.findByText('Error', {}, { timeout: 5000 })).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-CFG-007: a rejected save shows the server reason and keeps the dialog open', async () => {
server.use(
http.put('*/api/admin/plugins/trek-gotify/config', () =>
- HttpResponse.json({ error: 'config refused' }, { status: 400 })),
- )
- await openSettings()
+ HttpResponse.json({ error: 'config refused' }, { status: 400 })
+ )
+ );
+ await openSettings();
- fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
+ fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
- expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument()
+ expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument();
// Still open — the admin's edits are not thrown away on a refusal.
- expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
- })
+ expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-CFG-008: the dialog closes without saving', async () => {
- await openSettings()
+ await openSettings();
- fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
+ fireEvent.click(screen.getByRole('button', { name: /^close$/i }));
- expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument()
- })
+ expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-CFG-009: pre-fills a declared default when no value is stored', async () => {
server.use(
- http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
- fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, secret: false, default: 'https://auth.openbnb.org/authorize' }],
- config: {},
- })),
- )
- await openRowMenu(plugin({ instanceSettingsCount: 1 }))
- fireEvent.click(screen.getByText('Instance settings'))
+ http.get('*/api/admin/plugins/trek-gotify/config', () =>
+ HttpResponse.json({
+ fields: [
+ {
+ key: 'oauth_authorize_url',
+ label: 'Authorize URL',
+ input_type: 'text',
+ required: true,
+ secret: false,
+ default: 'https://auth.openbnb.org/authorize',
+ },
+ ],
+ config: {},
+ })
+ )
+ );
+ await openRowMenu(plugin({ instanceSettingsCount: 1 }));
+ fireEvent.click(screen.getByText('Instance settings'));
- expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize', {}, { timeout: 5000 })).toBeInTheDocument()
- })
+ expect(
+ await screen.findByDisplayValue('https://auth.openbnb.org/authorize', {}, { timeout: 5000 })
+ ).toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-CFG-010: a stored value wins over the default', async () => {
server.use(
- http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
- fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: false, secret: false, default: 'https://auth.openbnb.org/authorize' }],
- config: { oauth_authorize_url: 'https://mine.example' },
- })),
- )
- await openRowMenu(plugin({ instanceSettingsCount: 1 }))
- fireEvent.click(screen.getByText('Instance settings'))
+ http.get('*/api/admin/plugins/trek-gotify/config', () =>
+ HttpResponse.json({
+ fields: [
+ {
+ key: 'oauth_authorize_url',
+ label: 'Authorize URL',
+ input_type: 'text',
+ required: false,
+ secret: false,
+ default: 'https://auth.openbnb.org/authorize',
+ },
+ ],
+ config: { oauth_authorize_url: 'https://mine.example' },
+ })
+ )
+ );
+ await openRowMenu(plugin({ instanceSettingsCount: 1 }));
+ fireEvent.click(screen.getByText('Instance settings'));
- expect(await screen.findByDisplayValue('https://mine.example', {}, { timeout: 5000 })).toBeInTheDocument()
- expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument()
- })
+ expect(await screen.findByDisplayValue('https://mine.example', {}, { timeout: 5000 })).toBeInTheDocument();
+ expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument();
+ });
it('FE-COMP-PLUGINS-CFG-011: blocks Save while a required field is empty and names it', async () => {
- let called = false
+ let called = false;
server.use(
- http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
- fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true, secret: false }],
- config: {},
- })),
- http.put('*/api/admin/plugins/trek-gotify/config', () => { called = true; return HttpResponse.json({ config: {}, restarted: false }) }),
- )
- await openRowMenu(plugin({ instanceSettingsCount: 1 }))
- fireEvent.click(screen.getByText('Instance settings'))
- await screen.findByText('Client ID', {}, { timeout: 5000 })
+ http.get('*/api/admin/plugins/trek-gotify/config', () =>
+ HttpResponse.json({
+ fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true, secret: false }],
+ config: {},
+ })
+ ),
+ http.put('*/api/admin/plugins/trek-gotify/config', () => {
+ called = true;
+ return HttpResponse.json({ config: {}, restarted: false });
+ })
+ );
+ await openRowMenu(plugin({ instanceSettingsCount: 1 }));
+ fireEvent.click(screen.getByText('Instance settings'));
+ await screen.findByText('Client ID', {}, { timeout: 5000 });
- fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
+ fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
- expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument()
- expect(called).toBe(false)
- })
+ expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument();
+ expect(called).toBe(false);
+ });
it('FE-COMP-PLUGINS-CFG-012: Save paints its label with the accent-text token, so a light accent stays readable', async () => {
- await openSettings()
+ await openSettings();
// `--accent-text` flips per scheme and per light/dark; a hard white label vanishes on
// the near-white accent the dark theme uses by default.
- const save = screen.getByRole('button', { name: /^save$/i })
- expect(save.className).toContain('text-accent-text')
- expect(save.className).not.toContain('text-white')
- })
-})
+ const save = screen.getByRole('button', { name: /^save$/i });
+ expect(save.className).toContain('text-accent-text');
+ expect(save.className).not.toContain('text-white');
+ });
+});
/**
* TREK_PLUGINS_IGNORE_TREK_RANGE. The server turns its version gates into warnings; the
@@ -2544,75 +3149,109 @@ describe('AdminPluginsPanel — instance settings', () => {
describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
function bypassPanel(plugins: Record[], entry: Record | null) {
server.use(
- http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })),
- http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
- )
+ http.get('*/api/admin/plugins', () =>
+ HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })
+ ),
+ http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : []))
+ );
}
- const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
+ const incompatibleEntry = () =>
+ registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null });
it('FE-PLG-BYPASS-001: says so in the header', async () => {
- bypassPanel([plugin()], null)
- render( )
- expect(await screen.findByText('Version checks off')).toBeInTheDocument()
- })
+ bypassPanel([plugin()], null);
+ render( );
+ expect(await screen.findByText('Version checks off')).toBeInTheDocument();
+ });
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
- let posted: unknown = null
- bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
- server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
- posted = await request.json()
- return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } })
- }))
- withToast()
- fireEvent.click(await screen.findByText('Discover'))
+ let posted: unknown = null;
+ bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry());
+ server.use(
+ http.post('*/api/admin/plugins/install', async ({ request }) => {
+ posted = await request.json();
+ return HttpResponse.json({
+ id: 'trek-gotify',
+ version: '2.0.0',
+ trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' },
+ });
+ })
+ );
+ withToast();
+ fireEvent.click(await screen.findByText('Discover'));
- const btn = await screen.findByRole('button', { name: /^install anyway$/i })
- expect(btn).toBeEnabled()
- fireEvent.click(btn)
+ const btn = await screen.findByRole('button', { name: /^install anyway$/i });
+ expect(btn).toBeEnabled();
+ fireEvent.click(btn);
- const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
- expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
- expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument()
- expect(posted).toBeNull() // nothing sent until the admin accepts the risk
+ const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i });
+ expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument();
+ expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument();
+ expect(posted).toBeNull(); // nothing sent until the admin accepts the risk
- fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i }))
- await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' }))
- })
+ fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i }));
+ await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' }));
+ });
it('FE-PLG-BYPASS-003: cancelling the warning installs nothing', async () => {
- let posted = false
- bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
- server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
- withToast()
- fireEvent.click(await screen.findByText('Discover'))
- fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
+ let posted = false;
+ bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry());
+ server.use(
+ http.post('*/api/admin/plugins/install', () => {
+ posted = true;
+ return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null });
+ })
+ );
+ withToast();
+ fireEvent.click(await screen.findByText('Discover'));
+ fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }));
- const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
- fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
- await waitFor(() => expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument())
- expect(posted).toBe(false)
- })
+ const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i });
+ fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }));
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument()
+ );
+ expect(posted).toBe(false);
+ });
it('FE-PLG-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
- bypassPanel([plugin()], null)
- server.use(http.post('*/api/admin/plugins/upload', () =>
- HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } })))
- const { container } = withToast()
- await screen.findByText('Gotify')
+ bypassPanel([plugin()], null);
+ server.use(
+ http.post('*/api/admin/plugins/upload', () =>
+ HttpResponse.json({
+ id: 'trek-new',
+ version: '1.0.0',
+ replaced: false,
+ trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' },
+ })
+ )
+ );
+ const { container } = withToast();
+ await screen.findByText('Gotify');
- await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, new File(['zip'], 'plugin.zip', { type: 'application/zip' }))
+ await userEvent.upload(
+ container.querySelector('input[type="file"]') as HTMLInputElement,
+ new File(['zip'], 'plugin.zip', { type: 'application/zip' })
+ );
- const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i })
- expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
- expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument()
- })
+ const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i });
+ expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument();
+ expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument();
+ });
it('FE-PLG-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
- bypassPanel([plugin({
- dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0',
- trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
- })], null)
- render( )
- expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument()
- })
-})
+ bypassPanel(
+ [
+ plugin({
+ dependencyStatus: 'ok',
+ trekRange: '>=3.2.0 <4.0.0',
+ hostVersion: '4.0.0',
+ trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
+ }),
+ ],
+ null
+ );
+ render( );
+ expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx
index 38cdf3c12..3bf2a97e4 100644
--- a/client/src/components/Admin/AdminPluginsPanel.tsx
+++ b/client/src/components/Admin/AdminPluginsPanel.tsx
@@ -1,33 +1,75 @@
-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 { PLUGIN_PERMISSIONS } from '@trek/shared';
import {
- 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,
-} from 'lucide-react'
-import PluginIcon from '../shared/PluginIcon'
-import { adminApi } from '../../api/client'
-import { useInstanceSettings } from './useInstanceSettings'
-import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass'
-import { deriveCaps } from './pluginCaps'
-import PluginPoiCategoryList from './PluginPoiCategoryList'
-import { usePluginStore } from '../../store/pluginStore'
-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'
+ AlertTriangle,
+ ArrowUpCircle,
+ ArrowUpDown,
+ Blocks,
+ Bot,
+ Bug,
+ Check,
+ ChevronDown,
+ CircleDot,
+ Database,
+ Download,
+ ExternalLink,
+ Github,
+ Globe,
+ History,
+ Info,
+ KeyRound,
+ Link2,
+ Loader2,
+ Lock,
+ MoreHorizontal,
+ PauseCircle,
+ Puzzle,
+ RefreshCw,
+ RotateCw,
+ Search,
+ ShieldAlert,
+ ShieldCheck,
+ SlidersHorizontal,
+ Trash2,
+ UploadCloud,
+} from 'lucide-react';
+import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import { adminApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { usePluginStore } from '../../store/pluginStore';
+import { POPOVER } from '../Packing/packingPopoverStyles';
import {
- SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
- SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
-} from '../Settings/settingsKit'
-import { POPOVER } from '../Packing/packingPopoverStyles'
+ SETTINGS_BUTTON_PRIMARY,
+ SETTINGS_ICON_BUTTON,
+ SettingRow,
+ SettingRows,
+ SettingsCard,
+ SettingsHint,
+ StatusPill,
+} from '../Settings/settingsKit';
+import ToggleSwitch from '../Settings/ToggleSwitch';
+import ConfirmDialog from '../shared/ConfirmDialog';
+import CustomSelect from '../shared/CustomSelect';
+import { EditorField, INPUT } from '../shared/dialogParts';
+import {
+ DialogButton,
+ DialogFooter,
+ DialogHeader,
+ DialogSection,
+ DialogShell,
+ DialogTile,
+ FooterSpacer,
+ NEUTRAL_TINT,
+ fs,
+} from '../shared/DialogShell';
+import EmptyState from '../shared/EmptyState';
+import PluginIcon from '../shared/PluginIcon';
+import { useToast } from '../shared/Toast';
+import { Tooltip } from '../shared/Tooltip';
+import { deriveCaps } from './pluginCaps';
+import PluginPoiCategoryList from './PluginPoiCategoryList';
+import { useInstanceSettings } from './useInstanceSettings';
+import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass';
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -38,142 +80,164 @@ import { POPOVER } from '../Packing/packingPopoverStyles'
* tile; the security section at the bottom explains the model honestly.
*/
-interface PluginDep { id: string; version: string }
-interface VersionMismatch { id: string; wanted: string; installed: string }
-type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible'
-interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] }
-interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
+interface PluginDep {
+ id: string;
+ version: string;
+}
+interface VersionMismatch {
+ id: string;
+ wanted: string;
+ installed: string;
+}
+type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible';
+interface PluginDependencies {
+ requiredAddons: string[];
+ pluginDependencies: PluginDep[];
+}
+interface DependencyIssues {
+ disabledAddons: string[];
+ missing: PluginDep[];
+ versionMismatch: VersionMismatch[];
+}
interface PluginRow {
- id: string
- name: string
- description: string | null
- type: string
- icon: string | null
- version: string | null
- status: string
- enabled: number
- last_error: string | null
- reviewed_at: string | null
- source_repo: string | null
- permissions: string
- capabilities: string
+ id: string;
+ name: string;
+ description: string | null;
+ type: string;
+ icon: string | null;
+ version: string | null;
+ status: string;
+ enabled: number;
+ last_error: string | null;
+ reviewed_at: string | null;
+ source_repo: string | null;
+ permissions: string;
+ capabilities: string;
/** The plugin needs OPERATOR-supplied egress hosts (a self-hosted target). */
- operatorEgress?: boolean
+ operatorEgress?: boolean;
/** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */
- egressHostCount?: number
+ egressHostCount?: number;
/** How many `scope:'instance'` settings fields the plugin declares — gates the
* "Instance settings" menu item without a per-plugin fetch. */
- instanceSettingsCount?: number
+ instanceSettingsCount?: number;
/** How many `scope:'instance'` actions the plugin declares — a plugin with actions
* but no settings fields still needs the menu item to run them. */
- instanceActionsCount?: number
- dependencies?: PluginDependencies
- dependencyStatus?: DependencyStatus
- dependencyIssues?: DependencyIssues
+ instanceActionsCount?: number;
+ dependencies?: PluginDependencies;
+ dependencyStatus?: DependencyStatus;
+ dependencyIssues?: DependencyIssues;
/** The TREK versions the plugin says it supports; null if it never declared any. */
- trekRange?: string | null
+ trekRange?: string | null;
/** The TREK this server is running — the server does the semver, the client just shows it. */
- hostVersion?: string
+ hostVersion?: string;
/** Non-null while the plugin runs outside its declared range on the operator's say-so. */
- trekRangeBypassed?: TrekRangeBypass | null
+ trekRangeBypassed?: TrekRangeBypass | null;
/** The author's signature was verified and their key pinned at install. False means the
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
- signed?: boolean
+ signed?: boolean;
/** Short form of the pinned key, for eyeballing against what the author reads out. */
- keyFingerprint?: string | null
+ keyFingerprint?: string | null;
/** Why an update was refused, if one was. `version` is the version that was refused. */
- updateBlock?: { code: string; detail: string | null; version: string | null } | null
+ updateBlock?: { code: string; detail: string | null; version: string | null } | null;
/** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */
- updateHold?: boolean
+ updateHold?: boolean;
}
interface RegistryItem {
- id: string
- name: string
- author: string
- description: string
- repo: string
- homepage?: string | null
- type: string
+ id: string;
+ name: string;
+ author: string;
+ description: string;
+ repo: string;
+ homepage?: string | null;
+ type: string;
/** Lucide icon name from the registry entry; absent → Blocks. */
- icon?: string | null
- latest: string | null
- minTrekVersion: string | null
+ icon?: string | null;
+ latest: string | null;
+ minTrekVersion: string | null;
/** The latest version's declared TREK range; null on a legacy registry entry. */
- trek?: string | null
- hostVersion?: string
+ trek?: string | null;
+ hostVersion?: string;
/** Whether the LATEST version can be installed on this TREK. Computed server-side. */
- compatible?: boolean
+ compatible?: boolean;
/** Newest installable version — the latest, an older fallback, or null if none fits. */
- latestCompatible?: string | null
- reviewedAt: string | null
- downloadCount?: number | null
- screenshotUrl: string | null
- requiredAddons?: string[]
- pluginDependencies?: PluginDep[]
+ latestCompatible?: string | null;
+ reviewedAt: string | null;
+ downloadCount?: number | null;
+ screenshotUrl: string | null;
+ requiredAddons?: string[];
+ pluginDependencies?: PluginDep[];
/** The latest version ships an author signature and the entry declares a key. */
- signed?: boolean
+ signed?: boolean;
/** The full key — public, and carried in full because re-trust compares it exactly. */
- authorPublicKey?: string | null
+ authorPublicKey?: string | null;
}
/** One published version, with its SERVER-computed compat verdict (the client has no semver). */
interface VersionInfo {
- version: string
- publishedAt: string | null
- size: number | null
- signed: boolean
+ version: string;
+ publishedAt: string | null;
+ size: number | null;
+ signed: boolean;
/** The declared TREK requirement, or null when the entry carries no bounds at all. */
- trek: string | null
- compatible: boolean
+ trek: string | null;
+ compatible: boolean;
}
interface RegistryDetail extends RegistryItem {
- size: number | null
- publishedAt: string | null
+ size: number | null;
+ publishedAt: string | null;
/** Every published version, newest first — the version picker's data. */
- versions?: VersionInfo[]
+ versions?: VersionInfo[];
manifest: {
// Optional because a registry may omit an empty list — the detail view has to
// degrade rather than throw halfway through its render.
- permissions?: string[]
- egress?: string[]
+ permissions?: string[];
+ egress?: string[];
/** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */
- operatorEgress?: boolean
- settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
- license: string | null
- icon: string | null
- requiredAddons?: string[]
- pluginDependencies?: PluginDep[]
+ operatorEgress?: boolean;
+ settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>;
+ license: string | null;
+ icon: string | null;
+ requiredAddons?: string[];
+ pluginDependencies?: PluginDep[];
/** Display slice of the manifest's capabilities — drives the same chips as an installed row. */
capabilities?: {
- widget?: { slot?: string }
- tripPage?: { replaces?: string[] }
+ widget?: { slot?: string };
+ tripPage?: { replaces?: string[] };
/** Tools the plugin will publish on the MCP server once mcp:tools is granted. */
- mcpTools?: Array<{ name: string; title?: string; description: string }>
+ mcpTools?: Array<{ name: string; title?: string; description: string }>;
/** Explore-pill categories it adds (#1781), checked again before anything draws them. */
- poiCategories?: unknown
- }
- } | null
+ poiCategories?: unknown;
+ };
+ } | null;
}
/** 409 error-body shape from POST /activate when a dependency blocks activation. */
interface ActivateErr {
response?: {
- status?: number
- data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[]; addons?: string[]; missing?: PluginDep[]; versionMismatch?: VersionMismatch[] }
- }
+ status?: number;
+ data?: {
+ code?: string;
+ error?: string;
+ newPermissions?: string[];
+ newEgress?: string[];
+ addons?: string[];
+ missing?: PluginDep[];
+ versionMismatch?: VersionMismatch[];
+ };
+ };
}
/** The server's error envelope. Reading `code` — not the message text — is what lets the
* UI tell a rotated key (overridable) from a signature that doesn't verify (never). */
function errBody(e: unknown): { error?: string; code?: string } {
- return (e as { response?: { data?: { error?: string; code?: string } } })?.response?.data ?? {}
+ return (e as { response?: { data?: { error?: string; code?: string } } })?.response?.data ?? {};
}
/** The ONE signature refusal an admin may override, because a rotation has a benign
* explanation. SIGNATURE_INVALID / _MISSING / _INCOMPLETE mean the bytes are not what the
* author signed — those get an explanation and no override button at all. */
-const RETRUSTABLE = 'SIGNATURE_KEY_CHANGED'
-const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'SIGNATURE_INCOMPLETE']
+const RETRUSTABLE = 'SIGNATURE_KEY_CHANGED';
+const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'SIGNATURE_INCOMPLETE'];
/**
* What the signature dialog needs to talk about a plugin — deliberately NOT a PluginRow.
@@ -184,7 +248,7 @@ const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING',
* admin most often meets these codes for the first time. Name + pinned fingerprint is all the
* dialog ever reads, and both a row and a registry entry can supply that.
*/
-type SigSubject = { id: string; name: string; keyFingerprint: string | null }
+type SigSubject = { id: string; name: string; keyFingerprint: string | null };
/**
* Is a recorded update block still describing the version on offer?
@@ -196,20 +260,20 @@ type SigSubject = { id: string; name: string; keyFingerprint: string | null }
* so the block stands: silently dropping the last thing we knew would be the worse failure.
*/
function blockIsCurrent(p: PluginRow, latestVer?: string): boolean {
- if (!p.updateBlock) return false
+ if (!p.updateBlock) return false;
// A block describes a refused REGISTRY update. Once a plugin is sideloaded or dev-linked
// it has left the registry trust model — the running code is whatever the admin supplied,
// and a block about an author signing key says nothing about it. The server clears the
// block on both paths; this makes it impossible to render a stale one regardless.
- if (!isRegistrySourced(p.source_repo)) return false
- if (!latestVer || !p.updateBlock.version) return true
- return latestVer === p.updateBlock.version
+ if (!isRegistrySourced(p.source_repo)) return false;
+ if (!latestVer || !p.updateBlock.version) return true;
+ return latestVer === p.updateBlock.version;
}
-type T = (k: string, p?: Record) => string
-type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page'
-type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'
-type SortKey = 'name' | 'recent' | 'updates' | 'downloads'
+type T = (k: string, p?: Record) => string;
+type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page';
+type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err';
+type SortKey = 'name' | 'recent' | 'updates' | 'downloads';
// Runtime health → dot colour on the icon tile.
const HEALTH: Record = {
@@ -219,38 +283,54 @@ const HEALTH: Record = {
inactive: 'bg-content-faint/60',
disabled: 'bg-warning',
incompatible: 'bg-warning',
-}
+};
// Known permissions → human-readable i18n key; unknown ones render as raw code.
// Generated from the host's protocol/envelope.ts by
// server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy.
-const PERM_KEYS = PLUGIN_PERMISSIONS
+const PERM_KEYS = PLUGIN_PERMISSIONS;
-const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
+const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'];
function isNewer(a: string, b: string): boolean {
- const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0)
- const pa = nums(a), pb = nums(b)
+ const nums = (v: string) =>
+ v
+ .split('-')[0]
+ .split('.')
+ .map((n) => Number.parseInt(n, 10) || 0);
+ const pa = nums(a),
+ pb = nums(b);
for (let i = 0; i < 3; i++) {
- const x = pa[i] || 0, y = pb[i] || 0
- if (x !== y) return x > y
+ const x = pa[i] || 0,
+ y = pb[i] || 0;
+ if (x !== y) return x > y;
}
- return !a.includes('-') && b.includes('-')
+ return !a.includes('-') && b.includes('-');
}
function parseJson(raw: string | null | undefined, fallback: T): T {
- try { const v = JSON.parse(raw || '') as T; return v ?? fallback } catch { return fallback }
+ try {
+ const v = JSON.parse(raw || '') as T;
+ return v ?? fallback;
+ } catch {
+ return fallback;
+ }
}
-interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean }
+interface DepChip {
+ icon: React.ComponentType<{ size?: number; className?: string }>;
+ label: string;
+ blocked: boolean;
+ warn?: boolean;
+}
// A plugin's declared dependencies as chips — a required addon (amber when that
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
// or the TREK version itself (amber when this server has outgrown the plugin's range,
// which is the one blocker the admin cannot fix by flipping something else on).
function deriveDeps(p: PluginRow, t: T): DepChip[] {
- const out: DepChip[] = []
- const issues = p.dependencyIssues
+ const out: DepChip[] = [];
+ const issues = p.dependencyIssues;
if (p.dependencyStatus === 'hostIncompatible') {
out.push({
icon: AlertTriangle,
@@ -258,18 +338,24 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
? t('admin.plugins.dep.trekIncompatible', { range: p.trekRange, host: p.hostVersion ?? '?' })
: t('admin.plugins.dep.trekUnknown'),
blocked: true,
- })
+ });
}
- const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
- if (bypassed) out.push(bypassed)
+ const bypassed = bypassChip(p.trekRangeBypassed, t); // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
+ if (bypassed) out.push(bypassed);
for (const a of p.dependencies?.requiredAddons ?? []) {
- out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
+ out.push({
+ icon: Blocks,
+ label: t('admin.plugins.cap.requiresAddon', { addon: a }),
+ blocked: !!issues?.disabledAddons.includes(a),
+ });
}
for (const d of p.dependencies?.pluginDependencies ?? []) {
- const blocked = !!(issues?.missing.some(m => m.id === d.id) || issues?.versionMismatch.some(m => m.id === d.id))
- out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked })
+ const blocked = !!(
+ issues?.missing.some((m) => m.id === d.id) || issues?.versionMismatch.some((m) => m.id === d.id)
+ );
+ out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked });
}
- return out
+ return out;
}
/**
@@ -281,33 +367,43 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
* grey button. The plugin is perfectly usable, just not at its newest.
*/
-function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
- if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
+function installOffer(
+ item: RegistryItem,
+ t: T,
+ ignoreTrekRange = false
+): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
+ if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') };
const title = item.trek
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
- : t('admin.plugins.dep.trekUnknown')
- if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
+ : t('admin.plugins.dep.trekUnknown');
+ if (ignoreTrekRange) return bypassOffer(item, t, title); // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
if (item.latestCompatible) {
- return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
+ return {
+ blocked: false,
+ version: item.latestCompatible,
+ label: t('admin.plugins.installCompatible', { version: item.latestCompatible }),
+ title,
+ };
}
- return { blocked: true, label: t('admin.plugins.incompatible'), title }
+ 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)'
+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'
+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'
+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'
+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'
+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 }) {
@@ -315,20 +411,22 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
{children}
- )
+ );
}
function ReviewedBadge({ t }: { t: T }) {
- return
+ return ;
}
/** 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')}
+
- )
+ );
}
/**
@@ -342,7 +440,7 @@ function SideloadedBadge({ t }: { t: T }) {
* dilutes the amber into wallpaper, which is exactly what makes a warning worthless.
*/
function isRegistrySourced(sourceRepo: string | null | undefined): boolean {
- return !!sourceRepo && sourceRepo !== 'local:upload' && sourceRepo !== 'local:link'
+ return !!sourceRepo && sourceRepo !== 'local:upload' && sourceRepo !== 'local:link';
}
/**
@@ -360,13 +458,15 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
}>{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). */
@@ -375,79 +475,99 @@ function DevLinkBadge({ t }: { t: T }) {
}>{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}
- )
+ );
}
export default function AdminPluginsPanel() {
- const { t, locale } = useTranslation()
- const toast = useToast()
- const [runtimeOn, setRuntimeOn] = useState(false)
- const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
- const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
- const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell
- const [linkPath, setLinkPath] = useState('')
- const [plugins, setPlugins] = useState([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(false)
- const [busy, setBusy] = useState(null)
- const [view, setView] = useState<'installed' | 'discover'>('installed')
- const [registry, setRegistry] = useState(null)
- const [latest, setLatest] = useState>({})
+ const { t, locale } = useTranslation();
+ const toast = useToast();
+ const [runtimeOn, setRuntimeOn] = useState(false);
+ const [devLink, setDevLink] = useState(false); // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
+ const [ignoreTrekRange, setIgnoreTrekRange] = useState(false); // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
+ const bypass = useRangeBypass(); // its warning dialog/sheet state — shared with the other shell
+ const [linkPath, setLinkPath] = useState('');
+ const [plugins, setPlugins] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+ const [busy, setBusy] = useState(null);
+ const [view, setView] = useState<'installed' | 'discover'>('installed');
+ const [registry, setRegistry] = useState(null);
+ const [latest, setLatest] = useState>({});
// The registry entry per id — the re-trust dialog needs the NEW author key from it (in
// full: the server's equality check is exact), and the Discover/consent copy needs `signed`.
- const [regById, setRegById] = useState>({})
+ const [regById, setRegById] = useState>({});
// Open when a signature check refused an install/update. Carries the code, so the dialog
// knows whether an override may even be offered.
- const [signatureBlock, setSignatureBlock] = useState<{ subject: SigSubject; code: string; detail: string | null } | null>(null)
- const [retrusting, setRetrusting] = useState(false)
- const [detailFor, setDetailFor] = useState(null)
- const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
- const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
+ const [signatureBlock, setSignatureBlock] = useState<{
+ subject: SigSubject;
+ code: string;
+ detail: string | null;
+ } | null>(null);
+ const [retrusting, setRetrusting] = useState(false);
+ const [detailFor, setDetailFor] = useState(null);
+ const [errorsFor, setErrorsFor] = useState<{
+ id: string;
+ rows: Array<{ ts: string; level: string; message: string }>;
+ } | null>(null);
+ const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null);
// The admin-owned scope:'instance' settings form — shared logic with the phone shell.
- const settings = useInstanceSettings()
- const [egressDraft, setEgressDraft] = useState('')
- const [egressSaving, setEgressSaving] = useState(false)
- const [egressError, setEgressError] = useState('')
- const [confirmUninstall, setConfirmUninstall] = useState(null)
+ const settings = useInstanceSettings();
+ const [egressDraft, setEgressDraft] = useState('');
+ const [egressSaving, setEgressSaving] = useState(false);
+ const [egressError, setEgressError] = useState('');
+ const [confirmUninstall, setConfirmUninstall] = useState(null);
// The version picker for an INSTALLED plugin ("Change version…"); versions land async
// from the registry detail endpoint, which computes each version's compat verdict.
- const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null)
+ const [versionPicker, setVersionPicker] = useState<{
+ plugin: PluginRow;
+ versions: VersionInfo[] | null;
+ failed: boolean;
+ } | null>(null);
// A picked version OLDER than the installed one — held here until the admin consents
// to the data risk (the plugin's data dir stays; older code may not understand it).
- const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null)
+ const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null);
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
// each must be shown, not silently overwritten by the last one.
- const [consentQueue, setConsentQueue] = useState>([])
+ const [consentQueue, setConsentQueue] = useState<
+ Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>
+ >([]);
// Open when enabling a plugin is blocked by missing/outdated plugin dependencies.
- const [depResolve, setDepResolve] = useState<{ plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } | null>(null)
- const [menu, setMenu] = useState(null)
+ const [depResolve, setDepResolve] = useState<{
+ plugin: PluginRow;
+ missing: PluginDep[];
+ versionMismatch: VersionMismatch[];
+ } | null>(null);
+ const [menu, setMenu] = useState(null);
// Toolbar state.
- const [q, setQ] = useState('')
- const [typeFilter, setTypeFilter] = useState('all')
- const [statusFilter, setStatusFilter] = useState('all')
- const [sort, setSort] = useState('name')
+ const [q, setQ] = useState('');
+ const [typeFilter, setTypeFilter] = useState('all');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sort, setSort] = useState('name');
// 'updates' only ranks installed plugins, 'downloads' only the registry — snap
// the key back to name when switching tabs so the dropdown never carries a label
// for an option the active tab can't offer.
useEffect(() => {
- if (view === 'discover' && sort === 'updates') setSort('name')
- else if (view === 'installed' && sort === 'downloads') setSort('name')
- }, [view, sort])
+ if (view === 'discover' && sort === 'updates') setSort('name');
+ else if (view === 'installed' && sort === 'downloads') setSort('name');
+ }, [view, sort]);
// Sideload upload: drag a plugin .zip onto the panel or use the toolbar button.
- const [dragActive, setDragActive] = useState(false)
- const uploadInputRef = useRef(null)
- const dragDepth = useRef(0)
+ const [dragActive, setDragActive] = useState(false);
+ const uploadInputRef = useRef(null);
+ const dragDepth = useRef(0);
// Index the registry once per fetch: the version map the update badges read, plus the
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
@@ -455,31 +575,38 @@ export default function AdminPluginsPanel() {
// server-side) — not the absolute latest, so the banner never counts an update the
// update endpoint would refuse.
const indexRegistry = (items: RegistryItem[]) => {
- const vers: Record = {}
- const byId: Record = {}
- items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible })
- setLatest(vers)
- setRegById(byId)
- }
+ const vers: Record = {};
+ const byId: Record = {};
+ items.forEach((i) => {
+ byId[i.id] = i;
+ if (i.latestCompatible) vers[i.id] = i.latestCompatible;
+ });
+ setLatest(vers);
+ setRegById(byId);
+ };
const refresh = () => {
// Keep the app-wide active-plugin store in sync so widget/hero/tab consumers
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
- void usePluginStore.getState().loadPlugins()
- adminApi.plugins()
+ void usePluginStore.getState().loadPlugins();
+ adminApi
+ .plugins()
.then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => {
- setRuntimeOn(!!d.enabled)
- setDevLink(!!d.devLink)
- setIgnoreTrekRange(!!d.ignoreTrekRange)
- setPlugins(d.plugins || [])
+ setRuntimeOn(!!d.enabled);
+ setDevLink(!!d.devLink);
+ setIgnoreTrekRange(!!d.ignoreTrekRange);
+ setPlugins(d.plugins || []);
if ((d.plugins || []).length) {
- adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
+ adminApi
+ .pluginBrowse()
+ .then(indexRegistry)
+ .catch(() => {});
}
})
.catch(() => setError(true))
- .finally(() => setLoading(false))
- }
- useEffect(refresh, [])
+ .finally(() => setLoading(false));
+ };
+ useEffect(refresh, []);
// A signature refusal is not an ordinary "action failed": it is the one class of error
// where WHICH failure it was decides what the admin may do about it. Route it to the
@@ -490,344 +617,478 @@ export default function AdminPluginsPanel() {
// ALWAYS opens the dialog: falling back to a toast is what used to hide an invalid
// signature on a fresh install behind the same treatment as a network blip.
const routeSignatureError = (id: string, code?: string, detail?: string): boolean => {
- if (!code || !SIGNATURE_CODES.includes(code)) return false
- const row = plugins.find(p => p.id === id)
+ if (!code || !SIGNATURE_CODES.includes(code)) return false;
+ const row = plugins.find((p) => p.id === id);
const subject: SigSubject = row
? { id, name: row.name, keyFingerprint: row.keyFingerprint ?? null }
- // Not installed: no pinned key exists, so SIGNATURE_KEY_CHANGED cannot arise and the
- // dialog shows the no-override explanation — which is exactly right.
- : { id, name: regById[id]?.name ?? id, keyFingerprint: null }
- setSignatureBlock({ subject, code, detail: detail ?? null })
- return true
- }
+ : // Not installed: no pinned key exists, so SIGNATURE_KEY_CHANGED cannot arise and the
+ // dialog shows the no-override explanation — which is exactly right.
+ { id, name: regById[id]?.name ?? id, keyFingerprint: null };
+ setSignatureBlock({ subject, code, detail: detail ?? null });
+ return true;
+ };
const act = async (id: string, fn: () => Promise, ok: string) => {
- setBusy(id); setMenu(null)
- try { await fn(); toast.success(ok) }
- catch (e) {
- const { error, code } = errBody(e)
- if (!routeSignatureError(id, code, error)) toast.error(error || t('admin.plugins.actionError'))
+ setBusy(id);
+ setMenu(null);
+ try {
+ await fn();
+ toast.success(ok);
+ } catch (e) {
+ const { error, code } = errBody(e);
+ if (!routeSignatureError(id, code, error)) toast.error(error || t('admin.plugins.actionError'));
+ } finally {
+ setBusy(null);
+ refresh();
}
- finally { setBusy(null); refresh() }
- }
+ };
const openDiscover = () => {
- setView('discover')
+ setView('discover');
if (!registry) {
- adminApi.pluginBrowse()
- .then((items: RegistryItem[]) => { setRegistry(items); indexRegistry(items) })
- .catch(() => setRegistry([]))
+ adminApi
+ .pluginBrowse()
+ .then((items: RegistryItem[]) => {
+ setRegistry(items);
+ indexRegistry(items);
+ })
+ .catch(() => setRegistry([]));
}
- }
+ };
// The rescan/reload button rediscovers locally-installed plugins AND force-pulls
// the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a
// just-published plugin shows up right away instead of up to ~35 min later.
- const rescan = () => act('__rescan', async () => {
- await adminApi.pluginRescan()
- const items: RegistryItem[] = await adminApi.pluginBrowse(true)
- setRegistry(items)
- indexRegistry(items)
- }, t('admin.plugins.rescanned'))
+ const rescan = () =>
+ act(
+ '__rescan',
+ async () => {
+ await adminApi.pluginRescan();
+ const items: RegistryItem[] = await adminApi.pluginBrowse(true);
+ setRegistry(items);
+ indexRegistry(items);
+ },
+ t('admin.plugins.rescanned')
+ );
// Sideload a plugin archive (installs INACTIVE — the admin still consents on activation).
const uploadPlugin = async (file: File) => {
- setBusy('__upload'); setMenu(null)
+ setBusy('__upload');
+ setMenu(null);
try {
- const res = await adminApi.pluginUpload(file)
- setView('installed')
- toast.success(t('admin.plugins.uploaded', { name: res.id }))
- bypass.notice(res.id, res.trekRangeBypassed)
+ const res = await adminApi.pluginUpload(file);
+ setView('installed');
+ toast.success(t('admin.plugins.uploaded', { name: res.id }));
+ bypass.notice(res.id, res.trekRangeBypassed);
} catch (e) {
- toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
+ toast.error(
+ (e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')
+ );
} finally {
- setBusy(null); refresh()
+ setBusy(null);
+ refresh();
}
- }
- const pickUpload = () => uploadInputRef.current?.click()
+ };
+ const pickUpload = () => uploadInputRef.current?.click();
const onDragEnter = (e: DragEvent) => {
- if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return
- e.preventDefault(); dragDepth.current++; setDragActive(true)
- }
- const onDragLeave = () => { if (--dragDepth.current <= 0) { dragDepth.current = 0; setDragActive(false) } }
+ if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return;
+ e.preventDefault();
+ dragDepth.current++;
+ setDragActive(true);
+ };
+ const onDragLeave = () => {
+ if (--dragDepth.current <= 0) {
+ dragDepth.current = 0;
+ setDragActive(false);
+ }
+ };
const onDrop = (e: DragEvent) => {
- e.preventDefault(); dragDepth.current = 0; setDragActive(false)
- if (!runtimeOn) return
- const f = e.dataTransfer.files?.[0]
- if (f) void uploadPlugin(f)
- }
+ e.preventDefault();
+ dragDepth.current = 0;
+ setDragActive(false);
+ if (!runtimeOn) return;
+ const f = e.dataTransfer.files?.[0];
+ if (f) void uploadPlugin(f);
+ };
const openEgress = (id: string) => {
- setMenu(null)
- setEgressDraft(''); setEgressError('')
- adminApi.pluginEgressHosts(id)
- .then(d => setEgressFor({ id, supported: d.supported, hosts: d.hosts }))
- .catch(() => setEgressFor({ id, supported: false, hosts: [] }))
- }
+ setMenu(null);
+ setEgressDraft('');
+ setEgressError('');
+ adminApi
+ .pluginEgressHosts(id)
+ .then((d) => setEgressFor({ id, supported: d.supported, hosts: d.hosts }))
+ .catch(() => setEgressFor({ id, supported: false, hosts: [] }));
+ };
// Saving RE-SPAWNS the plugin: the child's egress guard is installed once at init and
// a second init is refused, so a live child's allow-list can never be widened in place.
const saveEgress = async (hosts: string[]) => {
- if (!egressFor) return
- setEgressSaving(true); setEgressError('')
+ if (!egressFor) return;
+ setEgressSaving(true);
+ setEgressError('');
try {
- const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts)
- setEgressFor({ ...egressFor, hosts: d.hosts })
- setEgressDraft('')
+ const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts);
+ setEgressFor({ ...egressFor, hosts: d.hosts });
+ setEgressDraft('');
} catch (e) {
- const err = e as { response?: { data?: { error?: string } } }
- setEgressError(err.response?.data?.error || t('common.error'))
+ const err = e as { response?: { data?: { error?: string } } };
+ setEgressError(err.response?.data?.error || t('common.error'));
} finally {
- setEgressSaving(false)
+ setEgressSaving(false);
}
- }
+ };
const openInstanceSettings = (p: { id: string; status: string }) => {
- setMenu(null)
- settings.open(p.id, p.status === 'active')
- }
+ setMenu(null);
+ settings.open(p.id, p.status === 'active');
+ };
const openErrors = (id: string) => {
- setMenu(null)
- adminApi.pluginErrors(id)
- .then((d: { errors: Array<{ ts: string; level: string; message: string }> }) => setErrorsFor({ id, rows: d.errors }))
- .catch(() => setErrorsFor({ id, rows: [] }))
- }
+ setMenu(null);
+ adminApi
+ .pluginErrors(id)
+ .then((d: { errors: Array<{ ts: string; level: string; message: string }> }) =>
+ setErrorsFor({ id, rows: d.errors })
+ )
+ .catch(() => setErrorsFor({ id, rows: [] }));
+ };
// A held plugin (deliberate non-latest install) is deliberately NOT an update candidate:
// the admin just rolled it back, and the banner nagging them straight back would make
// the rollback fight the UI. The row carries its own "paused" marker + resume instead.
- const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version))
- const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed'))
+ const updateAvailable = (p: PluginRow) =>
+ !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version));
+ const resumeUpdates = (p: PluginRow) =>
+ act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed'));
// A newer version exists but this TREK can't install it (and it isn't the one on offer):
// said passively on the row, so the admin learns a TREK upgrade unlocks it instead of
// wondering why no update shows. Null when the registry gives no range to point at.
const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => {
- const reg = regById[p.id]
- if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null
- if (latest[p.id] === reg.latest) return null
- const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
- return range ? { version: reg.latest, range } : null
- }
+ const reg = regById[p.id];
+ if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null;
+ if (latest[p.id] === reg.latest) return null;
+ const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null);
+ return range ? { version: reg.latest, range } : null;
+ };
// `warn` is the pre-install confirm for an entry the registry already flagged as
// incompatible; an artifact whose OWN manifest turns out to be out of range (the index
// was only a pre-download filter) is caught by the marker on the response instead.
- const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () =>
- act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed')))
- const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
+ const install = (id: string, version?: string, warn?: RangeWarning) =>
+ bypass.guard(warn, () =>
+ act(
+ id,
+ () =>
+ adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => {
+ if (!warn) bypass.notice(id, r?.trekRangeBypassed);
+ }),
+ t('admin.plugins.installed')
+ )
+ );
+ const restart = (id: string) =>
+ act(
+ id,
+ async () => {
+ await adminApi.pluginDeactivate(id);
+ await adminApi.pluginActivate(id);
+ },
+ t('admin.plugins.restarted')
+ );
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
const linkLocal = async () => {
- const p = linkPath.trim()
- if (!p) return
- setBusy('__link'); setMenu(null)
+ const p = linkPath.trim();
+ if (!p) return;
+ setBusy('__link');
+ setMenu(null);
try {
- const res = await adminApi.pluginLink(p)
- setView('installed')
- setLinkPath('')
- toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
- bypass.notice(res.id, res.trekRangeBypassed)
+ const res = await adminApi.pluginLink(p);
+ setView('installed');
+ setLinkPath('');
+ toast.success(t('admin.plugins.devLinkLinked', { id: res.id }));
+ bypass.notice(res.id, res.trekRangeBypassed);
} catch (e) {
- toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
+ toast.error(
+ (e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')
+ );
} finally {
- setBusy(null); refresh()
+ setBusy(null);
+ refresh();
}
- }
- const installedIds = new Set(plugins.map(p => p.id))
+ };
+ const installedIds = new Set(plugins.map((p) => p.id));
// Installed-but-disabled direct deps that enabling `p` will auto-enable first.
const autoEnabledDeps = (p: PluginRow) =>
(p.dependencies?.pluginDependencies ?? [])
- .map(d => plugins.find(x => x.id === d.id))
+ .map((d) => plugins.find((x) => x.id === d.id))
.filter((x): x is PluginRow => !!x && x.enabled === 0)
- .map(x => x.name)
+ .map((x) => x.name);
// Shared handling for a failed activation: route each 409 code to the right fix
// (consent dialog, download-dependency dialog, or a clear toast).
const onActivateError = (p: PluginRow, e: ActivateErr) => {
- const d = e?.response?.data
+ const d = e?.response?.data;
if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') {
- setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }])
+ setConsentQueue((qq) => [
+ ...qq,
+ {
+ plugin: p,
+ version: latest[p.id] ?? p.version ?? '',
+ newPermissions: d.newPermissions ?? [],
+ newEgress: d.newEgress ?? [],
+ },
+ ]);
} else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') {
- toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: (d.addons ?? []).join(', ') }))
+ toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: (d.addons ?? []).join(', ') }));
} else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') {
- setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] })
+ setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] });
} else {
// DEPENDENCY_CYCLE and everything else surface their server message.
- toast.error(d?.error || t('admin.plugins.actionError'))
+ toast.error(d?.error || t('admin.plugins.actionError'));
}
- }
+ };
const attemptActivate = (p: PluginRow) => {
- const cascaded = autoEnabledDeps(p)
- return adminApi.pluginActivate(p.id)
+ const cascaded = autoEnabledDeps(p);
+ return adminApi
+ .pluginActivate(p.id)
.then(() => {
- toast.success(t('admin.plugins.activated'))
- if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { plugins: cascaded.join(', ') }))
- setDepResolve(null)
+ toast.success(t('admin.plugins.activated'));
+ if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { plugins: cascaded.join(', ') }));
+ setDepResolve(null);
})
- .catch((e: ActivateErr) => onActivateError(p, e))
- }
+ .catch((e: ActivateErr) => onActivateError(p, e));
+ };
// Enable/disable a plugin. Re-enabling one whose update widened its permissions
// must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled
// required addon or a missing plugin dependency (409 ADDON_DISABLED /
// DEPENDENCY_MISSING) routes to the right remedy.
const toggle = (p: PluginRow) => {
- if (busy === p.id) return
- if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return }
- setBusy(p.id); setMenu(null)
- attemptActivate(p).finally(() => { setBusy(null); refresh() })
- }
+ if (busy === p.id) return;
+ if (p.enabled === 1) {
+ void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
+ return;
+ }
+ setBusy(p.id);
+ setMenu(null);
+ attemptActivate(p).finally(() => {
+ setBusy(null);
+ refresh();
+ });
+ };
// Download a missing/outdated plugin dependency (latest compatible for its range,
// transitively), then retry enabling the plugin that needed it.
const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => {
- if (busy === parent.id) return
- setBusy(parent.id)
- adminApi.pluginInstall(depId, { constraint, withDependencies: true })
+ if (busy === parent.id) return;
+ setBusy(parent.id);
+ adminApi
+ .pluginInstall(depId, { constraint, withDependencies: true })
.then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
- toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
- if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
- bypass.notice(depId, r?.trekRangeBypassed)
- return attemptActivate(parent)
+ toast.success(t('admin.plugins.dep.downloaded', { id: depId }));
+ if (r?.requiredAddons?.length)
+ toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }));
+ bypass.notice(depId, r?.trekRangeBypassed);
+ return attemptActivate(parent);
})
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
// dependency's author, not the parent's — route it under depId before falling through
// to the activation-error handling, which knows nothing about signature codes.
.catch((e: ActivateErr) => {
- const { error, code } = errBody(e)
- if (!routeSignatureError(depId, code, error)) onActivateError(parent, e)
+ const { error, code } = errBody(e);
+ if (!routeSignatureError(depId, code, error)) onActivateError(parent, e);
})
- .finally(() => { setBusy(null); refresh() })
- }
+ .finally(() => {
+ setBusy(null);
+ refresh();
+ });
+ };
// `version` pins the exact version to install (rollback / explicit switch); omitted,
// the server resolves the newest TREK-compatible version itself.
const runUpdate = (p: PluginRow, version?: string) => {
- setBusy(p.id); setMenu(null)
- return adminApi.pluginUpdate(p.id, version)
- .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
- if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
- else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
- bypass.notice(p.name, r.trekRangeBypassed)
+ setBusy(p.id);
+ setMenu(null);
+ return adminApi
+ .pluginUpdate(p.id, version)
+ .then(
+ (r: {
+ version: string;
+ activated: boolean;
+ newPermissions: string[];
+ newEgress: string[];
+ trekRangeBypassed?: TrekRangeBypass | null;
+ }) => {
+ if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0))
+ toast.success(t('admin.plugins.updated'));
+ else
+ setConsentQueue((qq) => [
+ ...qq,
+ { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress },
+ ]);
+ bypass.notice(p.name, r.trekRangeBypassed);
+ }
+ )
+ .catch((e) => {
+ const { error, code } = errBody(e);
+ if (!routeSignatureError(p.id, code, error)) toast.error(error || t('admin.plugins.actionError'));
})
- .catch(e => {
- const { error, code } = errBody(e)
- if (!routeSignatureError(p.id, code, error)) toast.error(error || t('admin.plugins.actionError'))
- })
- .finally(() => { setBusy(null); refresh() })
- }
+ .finally(() => {
+ setBusy(null);
+ refresh();
+ });
+ };
// Confirm a key rotation: re-pin the new key AND update, in one server call. There is no
// follow-up /update — a re-pin that waited for a second call would leave the plugin
// pinned to a key no install had ever been verified against if that call never came.
const confirmRetrust = (id: string, version: string, publicKey: string) => {
- setRetrusting(true)
- adminApi.pluginRetrust(id, version, publicKey)
+ setRetrusting(true);
+ adminApi
+ .pluginRetrust(id, version, publicKey)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
- setSignatureBlock(null)
+ setSignatureBlock(null);
// A re-trusted update widening permissions still needs consent — re-trusting a
// signing key says nothing about what the new code is allowed to do. Re-trust only
// ever fires for an INSTALLED plugin, so the row is there to consent against.
- const p = plugins.find(x => x.id === id)
+ const p = plugins.find((x) => x.id === id);
if (p && !r.activated && (r.newPermissions.length > 0 || r.newEgress.length > 0)) {
- setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
- } else toast.success(t('admin.plugins.retrusted'))
+ setConsentQueue((qq) => [
+ ...qq,
+ { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress },
+ ]);
+ } else toast.success(t('admin.plugins.retrusted'));
})
- .catch(e => toast.error(errBody(e).error || t('admin.plugins.actionError')))
- .finally(() => { setRetrusting(false); refresh() })
- }
+ .catch((e) => toast.error(errBody(e).error || t('admin.plugins.actionError')))
+ .finally(() => {
+ setRetrusting(false);
+ refresh();
+ });
+ };
// "Change version…" on an installed row: fetch the per-version list (with the server's
// compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale
// response can't populate a picker that was reopened for another row.
const openVersionPicker = (p: PluginRow) => {
- setMenu(null)
- setVersionPicker({ plugin: p, versions: null, failed: false })
- adminApi.pluginDetail(p.id)
- .then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur)))
- .catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur)))
- }
+ setMenu(null);
+ setVersionPicker({ plugin: p, versions: null, failed: false });
+ adminApi
+ .pluginDetail(p.id)
+ .then((d: RegistryDetail) =>
+ setVersionPicker((cur) => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur))
+ )
+ .catch(() => setVersionPicker((cur) => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur)));
+ };
// Switching DOWN keeps the plugin's data dir, which the older code may not understand —
// that asks for explicit consent first. Any other switch is the ordinary update path.
const pickVersion = (version: string) => {
- if (!versionPicker) return
- const p = versionPicker.plugin
- setVersionPicker(null)
- if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version })
- else void runUpdate(p, version)
- }
+ if (!versionPicker) return;
+ const p = versionPicker.plugin;
+ setVersionPicker(null);
+ if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version });
+ else void runUpdate(p, version);
+ };
// eslint-disable-next-line react-hooks/exhaustive-deps
- const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
+ const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]);
// One after another: busy is a single slot, so parallel updates would leave the
// other rows clickable mid-install and refresh once per plugin.
const updateAll = async () => {
- for (const p of updatable) await runUpdate(p)
- }
+ for (const p of updatable) await runUpdate(p);
+ };
// Installed list after search / type / status filters + sort.
const shownInstalled = useMemo(() => {
- const term = q.trim().toLowerCase()
- let rows = plugins.filter(p => {
- const matchesText = !term || `${p.name} ${p.description ?? ''}`.toLowerCase().includes(term)
- const matchesType = typeFilter === 'all' || p.type === typeFilter
- const st = statusFilter === 'all' ? true
- : statusFilter === 'on' ? p.enabled === 1 && p.status !== 'error'
- : statusFilter === 'off' ? p.enabled === 0
- : statusFilter === 'update' ? updateAvailable(p)
- : p.status === 'error'
- return matchesText && matchesType && st
- })
+ const term = q.trim().toLowerCase();
+ let rows = plugins.filter((p) => {
+ const matchesText = !term || `${p.name} ${p.description ?? ''}`.toLowerCase().includes(term);
+ const matchesType = typeFilter === 'all' || p.type === typeFilter;
+ const st =
+ statusFilter === 'all'
+ ? true
+ : statusFilter === 'on'
+ ? p.enabled === 1 && p.status !== 'error'
+ : statusFilter === 'off'
+ ? p.enabled === 0
+ : statusFilter === 'update'
+ ? updateAvailable(p)
+ : p.status === 'error';
+ return matchesText && matchesType && st;
+ });
rows = [...rows].sort((a, b) => {
if (sort === 'updates') {
- const ua = updateAvailable(a) ? 0 : 1, ub = updateAvailable(b) ? 0 : 1
- if (ua !== ub) return ua - ub
+ const ua = updateAvailable(a) ? 0 : 1,
+ ub = updateAvailable(b) ? 0 : 1;
+ if (ua !== ub) return ua - ub;
}
- return a.name.localeCompare(b.name)
- })
- return rows
+ return a.name.localeCompare(b.name);
+ });
+ return rows;
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [plugins, q, typeFilter, statusFilter, sort, latest])
+ }, [plugins, q, typeFilter, statusFilter, sort, latest]);
// Registry list after search / type filter.
const shownRegistry = useMemo(() => {
- if (!registry) return null
- const term = q.trim().toLowerCase()
- let items = registry.filter(r => {
- const matchesText = !term || `${r.name} ${r.author} ${r.description}`.toLowerCase().includes(term)
- const matchesType = typeFilter === 'all' || r.type === typeFilter
- return matchesText && matchesType
- })
+ if (!registry) return null;
+ const term = q.trim().toLowerCase();
+ let items = registry.filter((r) => {
+ const matchesText = !term || `${r.name} ${r.author} ${r.description}`.toLowerCase().includes(term);
+ const matchesType = typeFilter === 'all' || r.type === typeFilter;
+ return matchesText && matchesType;
+ });
items = [...items].sort((a, b) => {
- if (sort === 'downloads') return (b.downloadCount ?? 0) - (a.downloadCount ?? 0) || a.name.localeCompare(b.name)
- if (sort === 'recent') return (Date.parse(b.reviewedAt ?? '') || 0) - (Date.parse(a.reviewedAt ?? '') || 0) || a.name.localeCompare(b.name)
- return a.name.localeCompare(b.name)
- })
- return items
- }, [registry, q, typeFilter, sort])
+ if (sort === 'downloads') return (b.downloadCount ?? 0) - (a.downloadCount ?? 0) || a.name.localeCompare(b.name);
+ if (sort === 'recent')
+ return (
+ (Date.parse(b.reviewedAt ?? '') || 0) - (Date.parse(a.reviewedAt ?? '') || 0) || a.name.localeCompare(b.name)
+ );
+ return a.name.localeCompare(b.name);
+ });
+ return items;
+ }, [registry, q, typeFilter, sort]);
- const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all'
- const ready = runtimeOn && !loading && !error
+ 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
+ const detailBlocked = !!bypass.copy || !!signatureBlock || consentQueue.length > 0;
return (
- { if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}>
+
{
+ if (dragActive) e.preventDefault();
+ }}
+ onDragLeave={onDragLeave}
+ onDrop={onDrop}
+ >
{/* 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 = '' }} />
+
{
+ 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). */}
@@ -837,55 +1098,94 @@ export default function AdminPluginsPanel() {
icon={Blocks}
title={t('admin.plugins.title')}
hint={t('admin.plugins.subtitle')}
- badge={runtimeOn ? (
- <>
- {ignoreTrekRange && (
-
- }>{t('admin.plugins.rangeBypass.pill')}
-
- )}
-
}>{t('admin.plugins.runtimeOn')}
- >
- ) : undefined}
- action={ready ? (
- <>
-
-
-
-
-
-
- >
- ) : undefined}
+ badge={
+ runtimeOn ? (
+ <>
+ {ignoreTrekRange && (
+
+ }>
+ {t('admin.plugins.rangeBypass.pill')}
+
+
+ )}
+
}>
+ {t('admin.plugins.runtimeOn')}
+
+ >
+ ) : undefined
+ }
+ action={
+ ready ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : undefined
+ }
>
{loading ? (
- {t('common.loading')}
+
+ {t('common.loading')}
) : error ? (
-
{t('admin.plugins.loadError')}
+
+ {t('admin.plugins.loadError')}
+
) : !runtimeOn ? (
// Runtime switched off server-side: the notice replaces the whole body.
-
{t('admin.plugins.disabledTitle')}
-
{t('admin.plugins.disabledBody')}
+
+ {t('admin.plugins.disabledTitle')}
+
+
+ {t('admin.plugins.disabledBody')}
+
) : (
<>
{/* Dev-link: register + hot-reload a plugin from a local build dir (dev only). */}
{devLink && (
-
- )
+ );
}
function statusLabel(s: StatusFilter, t: T): string {
- return s === 'all' ? t('admin.plugins.allStatuses') : s === 'on' ? t('admin.plugins.status.active')
- : s === 'off' ? t('admin.plugins.stateOff') : s === 'update' ? t('admin.plugins.filterUpdate') : t('admin.plugins.status.error')
+ return s === 'all'
+ ? t('admin.plugins.allStatuses')
+ : s === 'on'
+ ? t('admin.plugins.status.active')
+ : s === 'off'
+ ? t('admin.plugins.stateOff')
+ : s === 'update'
+ ? t('admin.plugins.filterUpdate')
+ : t('admin.plugins.status.error');
}
function sortLabel(s: SortKey, t: T): string {
- return s === 'name' ? t('admin.plugins.sortName')
- : s === 'recent' ? t('admin.plugins.sortRecent')
- : s === 'downloads' ? t('admin.plugins.sortDownloads')
- : t('admin.plugins.sortUpdates')
+ return s === 'name'
+ ? t('admin.plugins.sortName')
+ : s === 'recent'
+ ? t('admin.plugins.sortRecent')
+ : s === 'downloads'
+ ? t('admin.plugins.sortDownloads')
+ : t('admin.plugins.sortUpdates');
}
/** An icon action on the card's head band, named by its tooltip. */
-function BandButton({ label, onClick, disabled, children }: { label: string; onClick: () => void; disabled?: boolean; children: ReactNode }) {
+function BandButton({
+ label,
+ onClick,
+ disabled,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ disabled?: boolean;
+ children: ReactNode;
+}) {
return (
{children}
- )
+ );
}
/** "Nothing matches": a faint search glyph over one quiet line. */
@@ -1117,112 +1569,221 @@ function NoMatch({ children }: { children: ReactNode }) {
{children}
- )
+ );
}
-function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) {
+function SegBtn({
+ active,
+ onClick,
+ label,
+ count,
+}: {
+ active: boolean;
+ onClick: () => void;
+ label: string;
+ count?: number;
+}) {
return (
-
+ active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'
+ }`}
+ style={fs(12.5, 'body')}
+ >
{label}
{count != null && (
- {count}
+
+ {count}
+
)}
- )
+ );
}
/** 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)
+ const [, reanchor] = useState(0);
useEffect(() => {
- if (!open) return
- const onMove = () => reanchor(n => n + 1)
- window.addEventListener('scroll', onMove, true)
- window.addEventListener('resize', onMove)
+ 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])
+ 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?: ReactNode
+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?: ReactNode;
}) {
- const open = menu === id
- const active = value !== options[0]?.[0]
- const btnRef = useRef
(null)
+ 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)
+ useFollowTrigger(open);
return (
-
setMenu(open ? null : id)} title={`${label}: ${valueLabel}`} aria-expanded={open}
+ setMenu(open ? null : id)}
+ title={`${label}: ${valueLabel}`}
+ aria-expanded={open}
className={`inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
- active ? 'border-[color:var(--text-primary)]' : 'border-edge'}`}
- style={fs(12.5, 'body')}>
+ active ? 'border-[color:var(--text-primary)]' : 'border-edge'
+ }`}
+ style={fs(12.5, 'body')}
+ >
{icon}
{label}:
{valueLabel}
-
+
- {open && createPortal(
-
- {options.map(([v, lbl]) => (
- { onPick(v); setMenu(null) }}
- className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
- {lbl}
-
-
- ))}
-
,
- document.body,
- )}
+ {open &&
+ createPortal(
+
+ {options.map(([v, lbl]) => (
+ {
+ onPick(v);
+ setMenu(null);
+ }}
+ className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`}
+ style={fs(12.5, 'body')}
+ >
+ {lbl}
+
+
+ ))}
+
,
+ document.body
+ )}
- )
+ );
}
-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
- onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
- onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
+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;
+ onToggle: () => void;
+ onUpdate: () => void;
+ onRestart: () => void;
+ onChangeVersion: () => void;
+ onResume: () => void;
+ onReviewBlock: () => void;
+ onErrors: () => void;
+ onEgress: () => void;
+ onSettings: () => void;
+ onUninstall: () => void;
}) {
- const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
- 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'
+ const caps = deriveCaps(
+ parseJson(p.permissions, []),
+ parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}),
+ t
+ );
+ 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.
- useFollowTrigger(menuOpen)
+ useFollowTrigger(menuOpen);
- const hostCount = p.egressHostCount ?? 0
+ 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' && }
@@ -1230,16 +1791,25 @@ 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 })}
-
+
+ {t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}
+
+
{t('admin.plugins.reviewBlock')}
@@ -1249,7 +1819,12 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
{newerIncompatible && (
- {t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}
+
+ {t('admin.plugins.newerNeedsTrek', {
+ version: newerIncompatible.version,
+ range: newerIncompatible.range,
+ })}
+
)}
{/* Held: the admin deliberately installed a non-latest version, so updates are
@@ -1258,45 +1833,58 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}
-
+
{t('admin.plugins.resumeUpdates')}
)}
{p.status === 'error' && p.last_error ? (
-
{p.last_error}
+
+
{p.last_error}
- ) : (caps.length > 0 || p.operatorEgress) && (
-
- {caps.map((c, i) => (
-
- {c.label}
-
- ))}
- {/* This plugin talks to a service only the OPERATOR can name (a self-hosted
+ ) : (
+ (caps.length > 0 || p.operatorEgress) && (
+
+ {caps.map((c, i) => (
+
+
+ {c.label}
+
+ ))}
+ {/* This plugin talks to a service only the OPERATOR can name (a self-hosted
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
Actionable, and warning-toned until at least one host exists, because
until then the plugin cannot reach anything and looks silently broken. */}
- {p.operatorEgress && (
-
- 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
-
- {hostCount > 0
- ? t('admin.plugins.allowedHosts.count').replace('{n}', String(hostCount))
- : t('admin.plugins.allowedHosts.add')}
-
-
- )}
-
+ {p.operatorEgress && (
+
+ 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`}
+ style={fs(11)}
+ >
+
+ {hostCount > 0
+ ? t('admin.plugins.allowedHosts.count').replace('{n}', String(hostCount))
+ : t('admin.plugins.allowedHosts.add')}
+
+
+ )}
+
+ )
)}
{deps.length > 0 && (
{deps.map((d, i) => (
- {d.label}
+
+ {d.label}
))}
@@ -1305,100 +1893,161 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
{hasUpdate && (
-
+ style={fs(11.5)}
+ >
{t('admin.plugins.updateTo', { version: latestVer })}
)}
-
+
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
- setMenu(menuOpen ? null : `row:${p.id}`)}
- aria-expanded={menuOpen} aria-label={t('admin.table.actions')}
- className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}>
+ setMenu(menuOpen ? null : `row:${p.id}`)}
+ aria-expanded={menuOpen}
+ aria-label={t('admin.table.actions')}
+ className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}
+ >
- {menuOpen && createPortal(
-
,
+ document.body
+ )}
- )
+ );
}
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
-const ROW_MENU_MAX_H = 260
+const ROW_MENU_MAX_H = 260;
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
- const r = trigger?.getBoundingClientRect()
- if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
- const spaceBelow = window.innerHeight - r.bottom
- const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
+ const r = trigger?.getBoundingClientRect();
+ if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 };
+ const spaceBelow = window.innerHeight - r.bottom;
+ const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow;
return {
position: 'fixed',
right: Math.max(8, window.innerWidth - r.right),
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
zIndex: 100,
- }
+ };
}
/** 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'
+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 }) {
+function MenuItem({
+ icon,
+ label,
+ onClick,
+ danger,
+}: {
+ icon: ReactNode;
+ label: string;
+ onClick: () => void;
+ danger?: boolean;
+}) {
return (
-
- {icon} {label}
+
+
+ {icon}
+ {' '}
+ {label}
- )
+ );
}
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
- const [failed, setFailed] = useState(false)
+ const [failed, setFailed] = useState(false);
return (
{url && !failed ? (
@@ -1409,43 +2058,67 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
)}
- )
+ );
}
-function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: {
- items: RegistryItem[] | null
- onInstall: (id: string, version?: string, warn?: RangeWarning) => void
- onOpenDetail: (item: RegistryItem) => void
- busy: string | null
- t: T
- installedIds: Set
- filtered: boolean
- ignoreTrekRange: boolean
+function RegistryGrid({
+ items,
+ onInstall,
+ onOpenDetail,
+ busy,
+ t,
+ installedIds,
+ filtered,
+ ignoreTrekRange,
+}: {
+ items: RegistryItem[] | null;
+ onInstall: (id: string, version?: string, warn?: RangeWarning) => void;
+ onOpenDetail: (item: RegistryItem) => void;
+ busy: string | null;
+ t: T;
+ installedIds: Set;
+ 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)
+ {items.map((item) => {
+ const installed = installedIds.has(item.id);
+ const offer = installOffer(item, t, ignoreTrekRange);
return (
-
onOpenDetail(item)}
+
onOpenDetail(item)}
// No press-scale on the card: shrinking it mid-click slides the Install
// button out from under the pointer, so the click retargets onto the card
// 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 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)]">
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onOpenDetail(item);
+ }
+ }}
+ 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')}
+
)}
@@ -1453,56 +2126,84 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
-
{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)}
)}
- { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
+ {
+ e.stopPropagation();
+ onInstall(item.id, offer.version, offer.warn);
+ }}
disabled={busy === item.id || installed || offer.blocked}
title={installed ? undefined : offer.title}
- className={SMALL_PRIMARY} style={fs(12, 'body')}>
+ className={SMALL_PRIMARY}
+ style={fs(12, 'body')}
+ >
{installed ? t('admin.plugins.installed') : offer.label}
- )
+ );
})}
- )
+ );
}
// 1234 -> "1.2k" — GitHub-style compact download counts for the browse cards.
// The M threshold sits at the k-rounding boundary so 999 950 is "1M", not "1000k".
function formatCompactCount(n: number): string {
- if (n >= 999_500) return `${Math.round(n / 100_000) / 10}M`
- if (n >= 1000) return `${Math.round(n / 100) / 10}k`
- return String(n)
+ if (n >= 999_500) return `${Math.round(n / 100_000) / 10}M`;
+ if (n >= 1000) return `${Math.round(n / 100) / 10}k`;
+ return String(n);
}
// A permission rendered human-readable when known, else as its raw code.
function PermLabel({ perm, t }: { perm: string; t: T }) {
- return PERM_KEYS.includes(perm)
- ?
{t(`admin.plugins.perm.${perm}` as never)}
- :
{perm}
+ return PERM_KEYS.includes(perm) ? (
+
{t(`admin.plugins.perm.${perm}` as never)}
+ ) : (
+
+ {perm}
+
+ );
}
/** A white box of short lines between hairlines, for the lists inside a dialog. */
function DialogList({ children }: { children: ReactNode }) {
- return
+ return (
+
+ );
}
/** One line of a DialogList: an icon, what it is, and room for an action on the right. */
@@ -1510,68 +2211,124 @@ function DialogListItem({ icon, children, trailing }: { icon?: ReactNode; childr
return (
{icon && {icon} }
- {children}
+
+ {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}
+
+ {icon}
+ {children}
- )
+ );
}
/** A raised tile for a dialog's head band. */
function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) {
- return
{children}
+ return (
+
+ {children}
+
+ );
}
/**
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
* component rather than ConfirmDialog because the notice mode has no cancel button.
*/
-function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
- copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; 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 (
} tint={WARN_TINT} labelId="plugin-range-bypass-title" title={title} onClose={onClose} />}
- footer={(
+ header={
+
+
+
+ }
+ tint={WARN_TINT}
+ labelId="plugin-range-bypass-title"
+ title={title}
+ onClose={onClose}
+ />
+ }
+ footer={
{confirm ? (
<>
{t('common.cancel')}
- {t('admin.plugins.installAnyway')}
+
+ {t('admin.plugins.installAnyway')}
+
>
) : (
- {t('common.ok')}
+
+ {t('common.ok')}
+
)}
- )}
+ }
>
- {body}
+
+ {body}
+
- )
+ );
}
/** 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
+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} />}
+ header={
+
+
+
+ }
+ tint={NEUTRAL_TINT}
+ labelId="plugin-error-log-title"
+ title={`${id} — ${t('admin.plugins.errorLog')}`}
+ onClose={onClose}
+ />
+ }
>
{rows.length === 0 ? (
{t('admin.plugins.noErrors')}
@@ -1579,7 +2336,9 @@ function ErrorLogDialog({ id, rows, t, onClose }: {
{rows.map((r, i) => (
- {r.level}
+
+ {r.level}
+
{r.ts}
{r.message}
@@ -1587,21 +2346,47 @@ function ErrorLogDialog({ id, rows, t, onClose }: {
)}
- )
+ );
}
/** 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
+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} />}
+ header={
+
+
+
+ }
+ tint={NEUTRAL_TINT}
+ labelId="plugin-egress-title"
+ title={`${data.id} — ${t('admin.plugins.allowedHosts')}`}
+ onClose={onClose}
+ />
+ }
>
{!data.supported ? (
{t('admin.plugins.allowedHosts.unsupported')}
@@ -1609,22 +2394,32 @@ function EgressDialog({ data, draft, setDraft, saving, error, onSave, onClose, t
<>
{t('admin.plugins.allowedHosts.hint')}
{data.hosts.length === 0 ? (
-
+
{t('admin.plugins.allowedHosts.none')}
) : (
- {data.hosts.map(h => (
- }
- trailing={(
+ {data.hosts.map((h) => (
+ }
+ trailing={
- onSave(data.hosts.filter(x => x !== h))}
+ onSave(data.hosts.filter((x) => x !== h))}
aria-label={t('common.delete')}
- className="grid h-8 w-8 place-items-center rounded-[9px] text-content-faint hover:bg-danger-soft hover:text-danger disabled:opacity-50">
+ className="grid h-8 w-8 place-items-center rounded-[9px] text-content-faint hover:bg-danger-soft hover:text-danger disabled:opacity-50"
+ >
- )}>
+ }
+ >
{h}
))}
@@ -1633,155 +2428,238 @@ function EgressDialog({ data, draft, setDraft, saving, error, onSave, onClose, t
setDraft(e.target.value)}
+ onChange={(e) => setDraft(e.target.value)}
placeholder="gotify.example.com"
aria-label={t('admin.plugins.allowedHosts.add')}
className={`${INPUT} flex-1 font-mono`}
/>
- onSave([...data.hosts, draft.trim()])}
- className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
+ onSave([...data.hosts, draft.trim()])}
+ className={SETTINGS_BUTTON_PRIMARY}
+ style={fs(13, 'body')}
+ >
{t('common.add')}
- {error && {error}
}
+ {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 = []
+ 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])
+ 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
+ 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 form = settings.form;
+ if (!form) return null;
const fieldLabel = (f: { key: string; label?: string; required?: boolean }) => (
- <>{f.label || f.key}{f.required && * }>
- )
+ <>
+ {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={(
+ header={
+
+
+
+ }
+ 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')}
+ void settings.save()}
+ >
+ {settings.saving && }
+ {t('common.save')}
- )}
+ }
>
- {groupToggles(form.fields).map(f => {
+ {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)} />} />
+ {f.map((c) => (
+ settings.setValue(c.key, form.values[c.key] !== true)}
+ />
+ }
+ />
))}
- )
+ );
}
- const inputId = `plugin-setting-${f.key}`
+ 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 }))]} />
+ 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)}
+ onChange={(e) => 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]
+ {form.actions.map((a) => {
+ const res = settings.actionResult[a.key];
return (
- settings.runAction(a)}
disabled={!form.active || settings.runningAction !== null || settings.saving}
- className={`${SMALL_BUTTON} ${a.danger ? 'text-danger' : ''}`} style={fs(12.5, 'body')}>
+ className={`${SMALL_BUTTON} ${a.danger ? 'text-danger' : ''}`}
+ style={fs(12.5, 'body')}
+ >
{settings.runningAction === a.key && }
{a.label}
- {a.hint && {a.hint} }
+ {a.hint && (
+
+ {a.hint}
+
+ )}
{res && (
-
+
{res.message || (res.ok ? t('common.success') : t('common.error'))}
)}
- )
+ );
})}
)}
- {settings.error && {settings.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
+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)
- const [failed, setFailed] = useState(false)
+ const [detail, setDetail] = useState(null);
+ const [failed, setFailed] = useState(false);
useEffect(() => {
- let alive = true
- adminApi.pluginDetail(item.id)
- .then((d: RegistryDetail) => { if (alive) setDetail(d) })
- .catch(() => { if (alive) setFailed(true) })
- return () => { alive = false }
- }, [item.id])
+ let alive = true;
+ adminApi
+ .pluginDetail(item.id)
+ .then((d: RegistryDetail) => {
+ if (alive) setDetail(d);
+ })
+ .catch(() => {
+ if (alive) setFailed(true);
+ });
+ return () => {
+ alive = false;
+ };
+ }, [item.id]);
- const manifest = detail?.manifest ?? null
- const permissions = manifest?.permissions ?? []
- const egress = manifest?.egress ?? []
- const settings = manifest?.settings ?? []
- const caps = manifest ? deriveCaps(permissions, manifest.capabilities ?? {}, t) : []
- const repoUrl = `https://github.com/${item.repo}`
- const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
- const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
+ const manifest = detail?.manifest ?? null;
+ const permissions = manifest?.permissions ?? [];
+ const egress = manifest?.egress ?? [];
+ const settings = manifest?.settings ?? [];
+ const caps = manifest ? deriveCaps(permissions, manifest.capabilities ?? {}, t) : [];
+ const repoUrl = `https://github.com/${item.repo}`;
+ const homepage =
+ item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null;
+ const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null;
// 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
+ const offer = installOffer(detail ?? item, t, ignoreTrekRange);
+ const access = caps.filter((c) => !c.net);
+ const linkClass = SETTINGS_ICON_BUTTON;
return (
}
+ tile={
+
+
+
+ }
tint={NEUTRAL_TINT}
labelId="plugin-detail-title"
title={item.name}
sub={`${item.author}${item.latest ? ` · v${item.latest}` : ''}`}
- pills={(
+ pills={
<>
- {item.reviewedAt && }>{t('admin.plugins.reviewed')}}
+ {item.reviewedAt && (
+ }>
+ {t('admin.plugins.reviewed')}
+
+ )}
>
- )}
+ }
onClose={onClose}
/>
- )}
- footer={(
+ }
+ footer={
-
+
+
+
-
+
+
+
{homepage && (
-
+
+
+
)}
- onInstall(item.id, offer.version, offer.warn)}
+ onInstall(item.id, offer.version, offer.warn)}
disabled={busy === item.id || installed || offer.blocked}
- title={installed ? undefined : offer.title}>
+ title={installed ? undefined : offer.title}
+ >
{busy === item.id ? : }
{installed ? t('admin.plugins.installed') : offer.label}
- )}
+ }
>
- {item.screenshotUrl && }
+ {item.screenshotUrl && (
+
+ )}
-
{item.description}
- {failed &&
{t('admin.plugins.detailError')}
}
+
+ {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}}
@@ -1841,7 +2766,8 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{!detail && !failed && (
- {t('common.loading')}
+
+ {t('common.loading')}
)}
@@ -1852,10 +2778,14 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
) : (
{access.map((c, i) => (
- }>{c.label}
+ }>
+ {c.label}
+
))}
{permissions.includes('db:own') && (
- }>{t('admin.plugins.perm.db:own')}
+ }>
+ {t('admin.plugins.perm.db:own')}
+
)}
)}
@@ -1869,14 +2799,22 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{manifest && (manifest.capabilities?.mcpTools?.length ?? 0) > 0 && (
- {(manifest.capabilities?.mcpTools ?? []).map(tool => (
+ {(manifest.capabilities?.mcpTools ?? []).map((tool) => (
}>
- {tool.name}
- {tool.title && {tool.title}
}
+
+ {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}
+
+ {tool.description}
+
))}
@@ -1884,39 +2822,51 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)}
-
+ itemClassName="text-content-secondary text-[length:calc(13px*var(--fs-scale-body,1))]"
+ />
{manifest && (egress.length > 0 || manifest.operatorEgress) && (
- {egress.map(h => (
- {h}
+ {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')}
+ }>
+ {t('admin.plugins.operatorEgressPill')}
+
)}
- {manifest.operatorEgress && {t('admin.plugins.operatorEgressHint')} }
+ {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}
))}
@@ -1931,9 +2881,14 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{/* 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 && (
+
)}
- {item.reviewedAt && }
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
)}
@@ -1945,18 +2900,29 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{(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 })}
-
+ {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 ?? '' })}
+
)
- ) : (
- {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}
- )}>
+ }
+ >
))}
@@ -1964,27 +2930,40 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)}
- )
+ );
}
/** 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{v.version}
+
+ {v.publishedAt && (
+
+ {new Date(v.publishedAt).toLocaleDateString(locale)}
+
+ )}
{v.signed && }
- )
+ );
}
function Meta({ k, v }: { k: string; v: string }) {
return (
-
{k}
-
{v}
+
+ {k}
+
+
+ {v}
+
- )
+ );
}
/**
@@ -1993,48 +2972,95 @@ function Meta({ k, v }: { k: string; v: string }) {
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
-function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
- plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
- t: T; locale: string; onPick: (version: string) => void; onClose: () => void
+function VersionPickerDialog({
+ plugin,
+ versions,
+ failed,
+ busy,
+ t,
+ locale,
+ onPick,
+ onClose,
+}: {
+ plugin: PluginRow;
+ versions: VersionInfo[] | null;
+ failed: boolean;
+ busy: string | null;
+ t: T;
+ locale: string;
+ onPick: (version: string) => void;
+ onClose: () => void;
}) {
return (
} tint={NEUTRAL_TINT} labelId="plugin-version-picker-title" title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose} />}
+ header={
+
+
+
+ }
+ tint={NEUTRAL_TINT}
+ labelId="plugin-version-picker-title"
+ title={t('admin.plugins.versionPickerTitle', { name: plugin.name })}
+ onClose={onClose}
+ />
+ }
>
- {failed && {t('admin.plugins.detailError')}
}
+ {failed && (
+
+ {t('admin.plugins.detailError')}
+
+ )}
{!failed && !versions && (
- {t('common.loading')}
+
+ {t('common.loading')}
)}
{versions?.length === 0 && {t('admin.plugins.noVersions')} }
{versions && versions.length > 0 && (
- {versions.map(v => {
- const current = v.version === plugin.version
+ {versions.map((v) => {
+ const current = v.version === plugin.version;
return (
-
-
+
+
+
+
{current ? (
-
}>{t('admin.plugins.installed')}
+
}>
+ {t('admin.plugins.installed')}
+
) : v.compatible ? (
-
onPick(v.version)} disabled={busy === plugin.id}
- className={SMALL_BUTTON} style={fs(11.5, 'body')}>
+ onPick(v.version)}
+ disabled={busy === plugin.id}
+ className={SMALL_BUTTON}
+ style={fs(11.5, 'body')}
+ >
{t('admin.plugins.versionSwitch', { version: v.version })}
) : (
- {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}
+
+ {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}
+
)}
- )
+ );
})}
)}
- )
+ );
}
/**
@@ -2051,44 +3077,73 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
* (The server enforces this too — it re-derives the condition and refuses anything but a
* changed key. This dialog is the convenience, not the control.)
*/
-function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
- data: { subject: SigSubject; code: string; detail: string | null }
- entry: RegistryItem | undefined
- busy: boolean; t: T
- onRetrust: (version: string, publicKey: string) => void
- onClose: () => void
+function SignatureBlockDialog({
+ data,
+ entry,
+ busy,
+ t,
+ onRetrust,
+ onClose,
+}: {
+ data: { subject: SigSubject; code: string; detail: string | null };
+ entry: RegistryItem | undefined;
+ busy: boolean;
+ t: T;
+ onRetrust: (version: string, publicKey: string) => void;
+ onClose: () => void;
}) {
- const canRetrust = data.code === RETRUSTABLE
- const newKey = entry?.authorPublicKey ?? null
- const version = entry?.latest ?? null
+ const canRetrust = data.code === RETRUSTABLE;
+ const newKey = entry?.authorPublicKey ?? null;
+ const version = entry?.latest ?? null;
// Only offer the override when we actually hold the new key + version to send: the
// request carries both, and the server compares the key exactly.
- const offerRetrust = canRetrust && !!newKey && !!version
+ const offerRetrust = canRetrust && !!newKey && !!version;
- const bodyKey = data.code === RETRUSTABLE ? 'admin.plugins.sig.keyChangedBody'
- : data.code === 'SIGNATURE_MISSING' ? 'admin.plugins.sig.missingBody'
- : data.code === 'SIGNATURE_INCOMPLETE' ? 'admin.plugins.sig.incompleteBody'
- : 'admin.plugins.sig.invalidBody'
+ const bodyKey =
+ data.code === RETRUSTABLE
+ ? 'admin.plugins.sig.keyChangedBody'
+ : data.code === 'SIGNATURE_MISSING'
+ ? 'admin.plugins.sig.missingBody'
+ : data.code === 'SIGNATURE_INCOMPLETE'
+ ? 'admin.plugins.sig.incompleteBody'
+ : 'admin.plugins.sig.invalidBody';
return (
} tint={WARN_TINT} labelId="plugin-signature-title" title={t('admin.plugins.sig.title', { name: data.subject.name })} onClose={onClose} />}
- footer={(
+ header={
+
+
+
+ }
+ 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 ? t('admin.plugins.sig.cancel') : t('common.close')}
+
{offerRetrust && (
onRetrust(version, newKey)} disabled={busy}>
- {busy && }{t('admin.plugins.sig.retrustConfirm')}
+ {busy && }
+ {t('admin.plugins.sig.retrustConfirm')}
)}
- )}
+ }
>
- {t(bodyKey as never)}
+
+ {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 && (
@@ -2101,53 +3156,89 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
)}
{!canRetrust && data.detail && (
- {data.detail}
+
+ {data.detail}
+
)}
- )
+ );
}
function KeyRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
- {label}
- {value}
+
+ {label}
+
+
+ {value}
+
- )
+ );
}
/** Client-side twin of the server's fingerprint: head…tail of the base64 payload. Display
* only — every equality check that matters happens server-side against the full key. */
function fingerprint(key: string | null): string | null {
- if (!key) return null
- const payload = key.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('untrusted comment')).pop()
- if (!payload) return null
- return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`
+ if (!key) return null;
+ const payload = key
+ .trim()
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .filter((l) => l && !l.startsWith('untrusted comment'))
+ .pop();
+ if (!payload) return null;
+ return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`;
}
-function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
- data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }
- unsigned: boolean
- t: T; onApprove: () => void; onLater: () => void
+function UpdateConsentDialog({
+ data,
+ unsigned,
+ t,
+ onApprove,
+ onLater,
+}: {
+ data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] };
+ unsigned: boolean;
+ t: T;
+ onApprove: () => void;
+ onLater: () => void;
}) {
return (
} tint={WARN_TINT} labelId="plugin-consent-title"
+ header={
+
+
+
+ }
+ 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={(
+ sub={t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}
+ subWraps
+ onClose={onLater}
+ />
+ }
+ footer={
{t('admin.plugins.updateLater')}
- {t('admin.plugins.updateApprove')}
+
+ {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:
@@ -2156,8 +3247,10 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
{data.newPermissions.length > 0 && (
- {data.newPermissions.map(perm => (
- }>
+ {data.newPermissions.map((perm) => (
+ }>
+
+
))}
@@ -2165,76 +3258,104 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
{data.newEgress.length > 0 && (
- {data.newEgress.map(host => (
- {host}
+ {data.newEgress.map((host) => (
+
+ {host}
+
))}
)}
- )
+ );
}
// Shown when enabling a plugin is blocked by missing/outdated plugin dependencies.
// Each dependency gets a one-click download (latest version satisfying its range,
// transitively) that then retries enabling the plugin.
-function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onClose }: {
- data: { plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
- t: T; busy: boolean; installedIds: Set
- onDownload: (depId: string, constraint?: string) => void; onClose: () => void
+function DependencyResolveDialog({
+ data,
+ t,
+ busy,
+ installedIds,
+ onDownload,
+ onClose,
+}: {
+ data: { plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] };
+ t: T;
+ busy: boolean;
+ installedIds: Set;
+ onDownload: (depId: string, constraint?: string) => void;
+ onClose: () => void;
}) {
const rows: Array<{ id: string; constraint: string; installed?: string }> = [
- ...data.missing.map(d => ({ id: d.id, constraint: d.version })),
- ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })),
- ]
+ ...data.missing.map((d) => ({ id: d.id, constraint: d.version })),
+ ...data.versionMismatch.map((d) => ({ id: d.id, constraint: d.wanted, installed: d.installed })),
+ ];
return (
} tint={WARN_TINT} labelId="plugin-deps-title"
+ header={
+
+
+
+ }
+ 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={(
+ sub={t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}
+ subWraps
+ onClose={onClose}
+ />
+ }
+ footer={
{t('common.cancel')}
- )}
+ }
>
- {rows.map(r => (
+ {rows.map((r) => (
-
{r.id}
+
+ {r.id}
+
{r.installed
? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed })
: t('admin.plugins.dep.requires', { version: r.constraint })}
-
onDownload(r.id, r.constraint)} disabled={busy}
- className={SMALL_PRIMARY} style={fs(12, 'body')}>
+ onDownload(r.id, r.constraint)}
+ disabled={busy}
+ className={SMALL_PRIMARY}
+ style={fs(12, 'body')}
+ >
{r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
))}
- {rows.some(r => !installedIds.has(r.id)) && {t('admin.plugins.dep.resolveHint')} }
+ {rows.some((r) => !installedIds.has(r.id)) && {t('admin.plugins.dep.resolveHint')} }
- )
+ );
}
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
// panel that lays out how plugins are contained, the limits, and the worst case.
function SecurityInfo({ t }: { t: T }) {
- const [open, setOpen] = useState(false)
+ const [open, setOpen] = useState(false);
const sections: Array<[string, string]> = [
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
@@ -2243,17 +3364,24 @@ function SecurityInfo({ t }: { t: T }) {
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
- ]
+ ];
return (
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">
+ title={
+ 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')}
-
+
- )}
+ }
>
@@ -2263,12 +3391,16 @@ function SecurityInfo({ t }: { t: T }) {
{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.test.tsx b/client/src/components/Admin/AuditLogPanel.test.tsx
index 4d076f0ef..0e121ef45 100644
--- a/client/src/components/Admin/AuditLogPanel.test.tsx
+++ b/client/src/components/Admin/AuditLogPanel.test.tsx
@@ -1,8 +1,8 @@
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010
-import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
+import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import AuditLogPanel from './AuditLogPanel';
@@ -44,7 +44,7 @@ describe('AuditLogPanel', () => {
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
- }),
+ })
);
render(
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
@@ -52,22 +52,14 @@ describe('AuditLogPanel', () => {
});
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
- server.use(
- http.get('/api/admin/audit-log', () =>
- HttpResponse.json({ entries: [], total: 0 }),
- ),
- );
+ server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 })));
render(
);
await screen.findByText('No audit entries yet.');
expect(document.querySelector('table')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
- server.use(
- http.get('/api/admin/audit-log', () =>
- HttpResponse.json({ entries: [ENTRY_1], total: 1 }),
- ),
- );
+ server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
render(
);
await screen.findByText('trip.create');
expect(screen.getByText('Time')).toBeInTheDocument();
@@ -89,11 +81,7 @@ describe('AuditLogPanel', () => {
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
];
- server.use(
- http.get('/api/admin/audit-log', () =>
- HttpResponse.json({ entries, total: 4 }),
- ),
- );
+ server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries, total: 4 })));
render(
);
await screen.findByText('a.username');
expect(screen.getByText('alice')).toBeInTheDocument();
@@ -121,9 +109,7 @@ describe('AuditLogPanel', () => {
details: {},
};
server.use(
- http.get('/api/admin/audit-log', () =>
- HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }),
- ),
+ http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }))
);
render(
);
await screen.findByText('a.nulls');
@@ -133,11 +119,7 @@ describe('AuditLogPanel', () => {
});
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
- server.use(
- http.get('/api/admin/audit-log', () =>
- HttpResponse.json({ entries: [ENTRY_1], total: 50 }),
- ),
- );
+ server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 50 })));
render(
);
await screen.findByText('trip.create');
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
@@ -152,7 +134,7 @@ describe('AuditLogPanel', () => {
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
}
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
- }),
+ })
);
const user = userEvent.setup();
render(
);
@@ -166,11 +148,7 @@ describe('AuditLogPanel', () => {
});
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
- server.use(
- http.get('/api/admin/audit-log', () =>
- HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }),
- ),
- );
+ server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 })));
render(
);
await screen.findByText('trip.create');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
@@ -191,7 +169,7 @@ describe('AuditLogPanel', () => {
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
}
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
- }),
+ })
);
const user = userEvent.setup();
render(
);
@@ -214,7 +192,7 @@ describe('AuditLogPanel', () => {
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
- }),
+ })
);
render(
);
const refreshBtn = screen.getByText('Refresh');
diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx
index c3c73cb85..4979ed003 100644
--- a/client/src/components/Admin/AuditLogPanel.tsx
+++ b/client/src/components/Admin/AuditLogPanel.tsx
@@ -1,78 +1,79 @@
-import React, { useCallback, useEffect, useState } from 'react'
-import { adminApi } from '../../api/client'
-import { useTranslation } from '../../i18n'
-import { RefreshCw, ClipboardList, Loader2 } from 'lucide-react'
-import { fs } from '../shared/DialogShell'
-import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
+import { ClipboardList, Loader2, RefreshCw } from 'lucide-react';
+import React, { useCallback, useEffect, useState } from 'react';
+import { adminApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { SETTINGS_BUTTON, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit';
+import { fs } from '../shared/DialogShell';
interface AuditEntry {
- id: number
- created_at: string
- user_id: number | null
- username: string | null
- user_email: string | null
- action: string
- resource: string | null
- details: Record
| null
- ip: string | null
+ id: number;
+ created_at: string;
+ user_id: number | null;
+ username: string | null;
+ user_email: string | null;
+ action: string;
+ resource: string | null;
+ details: Record | null;
+ ip: string | null;
}
interface AuditLogPanelProps {
- serverTimezone?: string
+ 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'
+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([])
- const [total, setTotal] = useState(0)
- const [offset, setOffset] = useState(0)
- const [loading, setLoading] = useState(true)
- const limit = 100
+ const { t, locale } = useTranslation();
+ const [entries, setEntries] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const limit = 100;
const loadFirstPage = useCallback(async () => {
- setLoading(true)
+ setLoading(true);
try {
- const data = await adminApi.auditLog({ limit, offset: 0 }) as {
- entries: AuditEntry[]
- total: number
- }
- setEntries(data.entries || [])
- setTotal(data.total ?? 0)
- setOffset(0)
+ const data = (await adminApi.auditLog({ limit, offset: 0 })) as {
+ entries: AuditEntry[];
+ total: number;
+ };
+ setEntries(data.entries || []);
+ setTotal(data.total ?? 0);
+ setOffset(0);
} catch {
- setEntries([])
- setTotal(0)
- setOffset(0)
+ setEntries([]);
+ setTotal(0);
+ setOffset(0);
} finally {
- setLoading(false)
+ setLoading(false);
}
- }, [])
+ }, []);
const loadMore = useCallback(async () => {
- const nextOffset = offset + limit
- setLoading(true)
+ const nextOffset = offset + limit;
+ setLoading(true);
try {
- const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
- entries: AuditEntry[]
- total: number
- }
- setEntries((prev) => [...prev, ...(data.entries || [])])
- setTotal(data.total ?? 0)
- setOffset(nextOffset)
+ const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as {
+ entries: AuditEntry[];
+ total: number;
+ };
+ setEntries((prev) => [...prev, ...(data.entries || [])]);
+ setTotal(data.total ?? 0);
+ setOffset(nextOffset);
} catch {
/* keep existing */
} finally {
- setLoading(false)
+ setLoading(false);
}
- }, [offset])
+ }, [offset]);
useEffect(() => {
- loadFirstPage()
- }, [loadFirstPage])
+ loadFirstPage();
+ }, [loadFirstPage]);
const fmtTime = (iso: string) => {
try {
@@ -80,41 +81,43 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
- })
+ });
} catch {
- return iso
+ return iso;
}
- }
+ };
const fmtDetails = (d: Record | null) => {
- if (!d || Object.keys(d).length === 0) return '—'
+ if (!d || Object.keys(d).length === 0) return '—';
try {
- return JSON.stringify(d)
+ return JSON.stringify(d);
} catch {
- return '—'
+ return '—';
}
- }
+ };
const userLabel = (e: AuditEntry) => {
- if (e.username) return e.username
- if (e.user_email) return e.user_email
- if (e.user_id != null) return `#${e.user_id}`
- return '—'
- }
+ if (e.username) return e.username;
+ if (e.user_email) return e.user_email;
+ if (e.user_id != null) return `#${e.user_id}`;
+ return '—';
+ };
- let body: React.ReactNode
+ let body: React.ReactNode;
if (loading && entries.length === 0) {
body = (
{t('common.loading')}
- )
+ );
} else if (entries.length === 0) {
- body = {t('admin.audit.empty')}
+ body = {t('admin.audit.empty')} ;
} else {
body = (
-
+
@@ -132,10 +135,16 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
{fmtTime(e.created_at)}
{/* A table cell ignores max-width under auto layout, so the box inside truncates. */}
- {userLabel(e)}
+
+ {userLabel(e)}
+
- {e.action}
+
+ {e.action}
+
{e.resource || '—'}
{e.ip || '—'}
@@ -145,7 +154,7 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
- )
+ );
}
return (
@@ -183,5 +192,5 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
)}
- )
+ );
}
diff --git a/client/src/components/Admin/BackupPanel.test.tsx b/client/src/components/Admin/BackupPanel.test.tsx
index db30a6a05..6e770feda 100644
--- a/client/src/components/Admin/BackupPanel.test.tsx
+++ b/client/src/components/Admin/BackupPanel.test.tsx
@@ -1,23 +1,23 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
-import userEvent from '@testing-library/user-event'
-import { resetAllStores, seedStore } from '../../../tests/helpers/store'
-import { useSettingsStore } from '../../store/settingsStore'
-import { server } from '../../../tests/helpers/msw/server'
-import { http, HttpResponse } from 'msw'
-import BackupPanel from './BackupPanel'
-import { ToastContainer } from '../shared/Toast'
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { server } from '../../../tests/helpers/msw/server';
+import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
+import { resetAllStores, seedStore } from '../../../tests/helpers/store';
+import { useSettingsStore } from '../../store/settingsStore';
+import { ToastContainer } from '../shared/Toast';
+import BackupPanel from './BackupPanel';
const manualBackup = {
filename: 'backup-2025-01-15.zip',
created_at: '2025-01-15T10:00:00Z',
size: 2048000,
-}
+};
const autoBackup = {
filename: 'auto-backup-2025-02-01.zip',
created_at: '2025-02-01T02:00:00Z',
size: 1024000,
-}
+};
function defaultBackupHandlers() {
return [
@@ -26,437 +26,484 @@ function defaultBackupHandlers() {
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
- }),
+ })
),
- ]
+ ];
}
function getToggleButton() {
// The enable toggle is a inside a that contains "Enable auto-backup"
- const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement
- return label.querySelector('button') as HTMLElement
+ const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement;
+ return label.querySelector('button') as HTMLElement;
}
describe('BackupPanel', () => {
beforeEach(() => {
- resetAllStores()
- seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
- vi.spyOn(window, 'confirm').mockReturnValue(true)
- server.use(...defaultBackupHandlers())
- })
+ resetAllStores();
+ seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any);
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ server.use(...defaultBackupHandlers());
+ });
afterEach(() => {
- vi.restoreAllMocks()
- vi.useRealTimers()
- server.resetHandlers()
- })
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ server.resetHandlers();
+ });
// BKP-001: Loading state
it('FE-ADMIN-BKP-001: shows loading spinner while fetching backups', async () => {
server.use(
http.get('/api/backup/list', async () => {
- await new Promise(resolve => setTimeout(resolve, 300))
- return HttpResponse.json({ backups: [] })
- }),
- )
- render( )
- expect(document.querySelector('.animate-spin')).toBeInTheDocument()
- })
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ return HttpResponse.json({ backups: [] });
+ })
+ );
+ render( );
+ expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+ });
// BKP-002: Empty state
it('FE-ADMIN-BKP-002: shows empty state when no backups exist', async () => {
- server.use(
- http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })),
- )
- render( )
+ server.use(http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })));
+ render( );
await waitFor(() => {
- expect(screen.getByText('No backups yet')).toBeInTheDocument()
- })
- expect(screen.getByText('Create first backup')).toBeInTheDocument()
- })
+ expect(screen.getByText('No backups yet')).toBeInTheDocument();
+ });
+ expect(screen.getByText('Create first backup')).toBeInTheDocument();
+ });
// BKP-003: Backup list renders filename, size, and date
it('FE-ADMIN-BKP-003: renders filename, formatted size, and date for a backup', async () => {
- render( )
+ render( );
await waitFor(() => {
- expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
- })
- expect(screen.getByText('2.0 MB')).toBeInTheDocument()
- })
+ expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
+ });
+ expect(screen.getByText('2.0 MB')).toBeInTheDocument();
+ });
// BKP-004: Auto-backup badge shown for auto-backup filenames
it('FE-ADMIN-BKP-004: shows Auto badge for auto-backup filenames', async () => {
- server.use(
- http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })),
- )
- render( )
+ server.use(http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })));
+ render( );
await waitFor(() => {
- expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument()
- })
- expect(screen.getByText('Auto')).toBeInTheDocument()
- })
+ expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument();
+ });
+ expect(screen.getByText('Auto')).toBeInTheDocument();
+ });
// BKP-005: Create backup success
it('FE-ADMIN-BKP-005: creates backup and shows success toast', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup();
server.use(
http.post('/api/backup/create', () => HttpResponse.json({ success: true })),
- http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
- )
- render(<> >)
+ http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] }))
+ );
+ render(
+ <>
+
+
+ >
+ );
await waitFor(() => {
- expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
- })
- await user.click(screen.getByTitle('Create Backup'))
+ expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
+ });
+ await user.click(screen.getByTitle('Create Backup'));
await waitFor(() => {
- expect(screen.getByText('Backup created successfully')).toBeInTheDocument()
- })
- })
+ expect(screen.getByText('Backup created successfully')).toBeInTheDocument();
+ });
+ });
// BKP-006: Restore opens confirmation modal
it('FE-ADMIN-BKP-006: clicking Restore opens confirmation modal', async () => {
- const user = userEvent.setup()
- render( )
+ const user = userEvent.setup();
+ render( );
await waitFor(() => {
- expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
- })
- await user.click(screen.getAllByText('Restore')[0])
+ expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
+ });
+ await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
- expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
- })
- expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1)
- expect(screen.getByText('Yes, restore')).toBeInTheDocument()
- expect(screen.getByText('Cancel')).toBeInTheDocument()
- })
+ expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
+ });
+ expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('Yes, restore')).toBeInTheDocument();
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ });
// BKP-007: Cancel dismisses modal without calling restore API
it('FE-ADMIN-BKP-007: cancel dismisses the restore modal without calling the API', async () => {
- const user = userEvent.setup()
- let restoreCalled = false
+ const user = userEvent.setup();
+ let restoreCalled = false;
server.use(
http.post('/api/backup/restore/:filename', () => {
- restoreCalled = true
- return HttpResponse.json({ success: true })
- }),
- )
- render( )
+ restoreCalled = true;
+ return HttpResponse.json({ success: true });
+ })
+ );
+ render( );
await waitFor(() => {
- expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
- })
- await user.click(screen.getAllByText('Restore')[0])
+ expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
+ });
+ await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
- expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
- })
- await user.click(screen.getByText('Cancel'))
+ expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
+ });
+ await user.click(screen.getByText('Cancel'));
await waitFor(() => {
- expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
- })
- expect(restoreCalled).toBe(false)
- })
+ expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument();
+ });
+ expect(restoreCalled).toBe(false);
+ });
// BKP-008: Backdrop click dismisses modal
it('FE-ADMIN-BKP-008: clicking the backdrop dismisses the restore modal', async () => {
- const user = userEvent.setup()
- render( )
+ const user = userEvent.setup();
+ render( );
await waitFor(() => {
- expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
- })
- await user.click(screen.getAllByText('Restore')[0])
+ expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
+ });
+ await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
- expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
- })
+ expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
+ });
// 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!)
+ const backdrop = document.querySelector('.trek-backdrop-enter') as HTMLElement;
+ expect(backdrop).toBeTruthy();
+ fireEvent.click(backdrop!);
await waitFor(() => {
- expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
- })
- })
+ expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument();
+ });
+ });
// BKP-009: Successful restore calls API and reloads after 1500ms
it('FE-ADMIN-BKP-009: successful restore shows toast and reloads after 1500ms', async () => {
- const user = userEvent.setup()
- server.use(
- http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })),
- )
- render(<> >)
+ const user = userEvent.setup();
+ server.use(http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })));
+ render(
+ <>
+
+
+ >
+ );
await waitFor(() => {
- expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
- })
+ expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
+ });
// Stub reload AFTER initial data load so we don't corrupt window.location during setup
- const reloadMock = vi.fn()
- vi.stubGlobal('location', { ...window.location, reload: reloadMock })
+ const reloadMock = vi.fn();
+ vi.stubGlobal('location', { ...window.location, reload: reloadMock });
- await user.click(screen.getAllByText('Restore')[0])
- await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument())
- await user.click(screen.getByText('Yes, restore'))
- await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument())
+ await user.click(screen.getAllByText('Restore')[0]);
+ await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument());
+ await user.click(screen.getByText('Yes, restore'));
+ await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument());
// Wait for the 1500ms reload timer to fire
- await new Promise(resolve => setTimeout(resolve, 1600))
- expect(reloadMock).toHaveBeenCalled()
- vi.unstubAllGlobals()
- }, 20000)
+ await new Promise((resolve) => setTimeout(resolve, 1600));
+ expect(reloadMock).toHaveBeenCalled();
+ vi.unstubAllGlobals();
+ }, 20000);
// BKP-010: Delete backup with confirm dialog
it('FE-ADMIN-BKP-010: deletes backup after confirm and shows success toast', async () => {
- const user = userEvent.setup()
- server.use(
- http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })),
- )
- render(<> >)
+ const user = userEvent.setup();
+ server.use(http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })));
+ render(
+ <>
+
+
+ >
+ );
await waitFor(() => {
- expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
- })
- const trashBtn = Array.from(document.querySelectorAll('button')).find(
- b => b.querySelector('svg.lucide-trash2'),
- ) as HTMLElement
- expect(trashBtn).toBeTruthy()
- await user.click(trashBtn!)
+ expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
+ });
+ const trashBtn = Array.from(document.querySelectorAll('button')).find((b) =>
+ b.querySelector('svg.lucide-trash2')
+ ) 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]!)
+ 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()
- })
+ expect(screen.getByText('Backup deleted')).toBeInTheDocument();
+ });
await waitFor(() => {
- expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument()
- })
- })
+ expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument();
+ });
+ });
// BKP-011: Auto-backup enable toggle shows interval controls
it('FE-ADMIN-BKP-011: enabling auto-backup shows interval controls', async () => {
- const user = userEvent.setup()
- render( )
+ const user = userEvent.setup();
+ render( );
await waitFor(() => {
- expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
- })
- expect(screen.queryByText('Hourly')).not.toBeInTheDocument()
- await user.click(getToggleButton())
+ expect(screen.getByText('Enable auto-backup')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('Hourly')).not.toBeInTheDocument();
+ await user.click(getToggleButton());
await waitFor(() => {
- expect(screen.getByText('Hourly')).toBeInTheDocument()
- expect(screen.getByText('Daily')).toBeInTheDocument()
- expect(screen.getByText('Weekly')).toBeInTheDocument()
- expect(screen.getByText('Monthly')).toBeInTheDocument()
- })
- })
+ expect(screen.getByText('Hourly')).toBeInTheDocument();
+ expect(screen.getByText('Daily')).toBeInTheDocument();
+ expect(screen.getByText('Weekly')).toBeInTheDocument();
+ expect(screen.getByText('Monthly')).toBeInTheDocument();
+ });
+ });
// BKP-012: Weekly interval shows day-of-week picker
it('FE-ADMIN-BKP-012: weekly interval shows day-of-week picker', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup();
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
- }),
- ),
- )
- render( )
+ })
+ )
+ );
+ render( );
await waitFor(() => {
- expect(screen.getByText('Weekly')).toBeInTheDocument()
- })
- expect(screen.queryByText('Sun')).not.toBeInTheDocument()
- await user.click(screen.getByText('Weekly'))
+ expect(screen.getByText('Weekly')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('Sun')).not.toBeInTheDocument();
+ await user.click(screen.getByText('Weekly'));
await waitFor(() => {
- expect(screen.getByText('Sun')).toBeInTheDocument()
- expect(screen.getByText('Mon')).toBeInTheDocument()
- expect(screen.getByText('Sat')).toBeInTheDocument()
- })
- expect(screen.queryByText('Day of month')).not.toBeInTheDocument()
- })
+ expect(screen.getByText('Sun')).toBeInTheDocument();
+ expect(screen.getByText('Mon')).toBeInTheDocument();
+ expect(screen.getByText('Sat')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('Day of month')).not.toBeInTheDocument();
+ });
// BKP-013: Save auto-settings calls API and shows toast
it('FE-ADMIN-BKP-013: saving auto-settings calls API and shows success toast', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup();
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
- }),
+ })
),
http.put('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
- }),
- ),
- )
- render(<> >)
+ })
+ )
+ );
+ render(
+ <>
+
+
+ >
+ );
await waitFor(() => {
- expect(screen.getByText('Weekly')).toBeInTheDocument()
- })
- await user.click(screen.getByText('Weekly'))
+ expect(screen.getByText('Weekly')).toBeInTheDocument();
+ });
+ await user.click(screen.getByText('Weekly'));
await waitFor(() => {
- const saveBtn = screen.getByRole('button', { name: /^save$/i })
- expect(saveBtn).not.toBeDisabled()
- })
- await user.click(screen.getByRole('button', { name: /^save$/i }))
+ const saveBtn = screen.getByRole('button', { name: /^save$/i });
+ expect(saveBtn).not.toBeDisabled();
+ });
+ await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => {
- expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument()
- })
- })
+ expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument();
+ });
+ });
// BKP-014: Save button disabled until settings changed
it('FE-ADMIN-BKP-014: save button is disabled until settings are changed', async () => {
- const user = userEvent.setup()
- render( )
+ const user = userEvent.setup();
+ render( );
await waitFor(() => {
- expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
- })
- const saveBtn = screen.getByRole('button', { name: /^save$/i })
- expect(saveBtn).toBeDisabled()
- await user.click(getToggleButton())
+ expect(screen.getByText('Enable auto-backup')).toBeInTheDocument();
+ });
+ const saveBtn = screen.getByRole('button', { name: /^save$/i });
+ expect(saveBtn).toBeDisabled();
+ await user.click(getToggleButton());
await waitFor(() => {
- expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
- })
- })
+ expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled();
+ });
+ });
// BKP-015: List request fails
it('FE-ADMIN-BKP-015: a failing list request toasts and keeps the empty state', async () => {
- server.use(http.get('/api/backup/list', () => HttpResponse.error()))
- render(<> >)
+ server.use(http.get('/api/backup/list', () => HttpResponse.error()));
+ render(
+ <>
+
+
+ >
+ );
- expect(await screen.findByText('Failed to load backups')).toBeInTheDocument()
- expect(screen.getByText('No backups yet')).toBeInTheDocument()
- })
+ expect(await screen.findByText('Failed to load backups')).toBeInTheDocument();
+ expect(screen.getByText('No backups yet')).toBeInTheDocument();
+ });
// BKP-016: Create fails
it('FE-ADMIN-BKP-016: a failing create toasts the error and re-enables the button', async () => {
- const user = userEvent.setup()
- server.use(http.post('/api/backup/create', () => HttpResponse.error()))
- render(<> >)
- await screen.findByText('backup-2025-01-15.zip')
+ const user = userEvent.setup();
+ server.use(http.post('/api/backup/create', () => HttpResponse.error()));
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('backup-2025-01-15.zip');
- await user.click(screen.getByTitle('Create Backup'))
+ await user.click(screen.getByTitle('Create Backup'));
- expect(await screen.findByText('Failed to create backup')).toBeInTheDocument()
- await waitFor(() => expect(screen.getByTitle('Create Backup')).toBeEnabled())
- })
+ expect(await screen.findByText('Failed to create backup')).toBeInTheDocument();
+ await waitFor(() => expect(screen.getByTitle('Create Backup')).toBeEnabled());
+ });
// BKP-017: Restore fails
it('FE-ADMIN-BKP-017: a failing restore surfaces the server message and clears the spinner', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup();
server.use(
http.post('/api/backup/restore/:filename', () =>
- HttpResponse.json({ error: 'archive is corrupt' }, { status: 400 }),
- ),
- )
- render(<> >)
- await screen.findByText('backup-2025-01-15.zip')
+ HttpResponse.json({ error: 'archive is corrupt' }, { status: 400 })
+ )
+ );
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('backup-2025-01-15.zip');
- await user.click(screen.getAllByText('Restore')[0])
- await user.click(await screen.findByText('Yes, restore'))
+ await user.click(screen.getAllByText('Restore')[0]);
+ await user.click(await screen.findByText('Yes, restore'));
- expect(await screen.findByText('archive is corrupt')).toBeInTheDocument()
- await waitFor(() => expect(screen.getAllByText('Restore')[0].closest('button')).toBeEnabled())
- })
+ expect(await screen.findByText('archive is corrupt')).toBeInTheDocument();
+ await waitFor(() => expect(screen.getAllByText('Restore')[0].closest('button')).toBeEnabled());
+ });
// BKP-018: Upload & restore happy path
it('FE-ADMIN-BKP-018: picking a file opens the modal and uploads it on confirm', async () => {
- const user = userEvent.setup()
- let uploaded = false
+ const user = userEvent.setup();
+ let uploaded = false;
server.use(
http.post('/api/backup/upload-restore', () => {
- uploaded = true
- return HttpResponse.json({ success: true })
- }),
- )
- render(<> >)
- await screen.findByText('backup-2025-01-15.zip')
+ uploaded = true;
+ return HttpResponse.json({ success: true });
+ })
+ );
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('backup-2025-01-15.zip');
- const reloadMock = vi.fn()
- vi.stubGlobal('location', { ...window.location, reload: reloadMock })
+ const reloadMock = vi.fn();
+ vi.stubGlobal('location', { ...window.location, reload: reloadMock });
- const input = document.querySelector('input[type="file"]') as HTMLInputElement
- await user.upload(input, new File(['zip'], 'restore-me.zip', { type: 'application/zip' }))
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+ await user.upload(input, new File(['zip'], 'restore-me.zip', { type: 'application/zip' }));
- expect(await screen.findByText('Restore Backup?')).toBeInTheDocument()
- expect(screen.getByText('restore-me.zip')).toBeInTheDocument()
+ expect(await screen.findByText('Restore Backup?')).toBeInTheDocument();
+ expect(screen.getByText('restore-me.zip')).toBeInTheDocument();
// The picked file is cleared from the input so the same file can be chosen again
- expect(input.value).toBe('')
+ expect(input.value).toBe('');
- await user.click(screen.getByText('Yes, restore'))
- await waitFor(() => expect(uploaded).toBe(true))
- expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument()
- vi.unstubAllGlobals()
- })
+ await user.click(screen.getByText('Yes, restore'));
+ await waitFor(() => expect(uploaded).toBe(true));
+ expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument();
+ vi.unstubAllGlobals();
+ });
// BKP-019: Upload & restore failure
it('FE-ADMIN-BKP-019: a failing upload restore toasts and re-enables the upload button', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup();
server.use(
- http.post('/api/backup/upload-restore', () => HttpResponse.json({ error: 'not a backup' }, { status: 400 })),
- )
- render(<> >)
- await screen.findByText('backup-2025-01-15.zip')
+ http.post('/api/backup/upload-restore', () => HttpResponse.json({ error: 'not a backup' }, { status: 400 }))
+ );
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('backup-2025-01-15.zip');
- const input = document.querySelector('input[type="file"]') as HTMLInputElement
- await user.upload(input, new File(['zip'], 'broken.zip', { type: 'application/zip' }))
- await user.click(await screen.findByText('Yes, restore'))
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+ await user.upload(input, new File(['zip'], 'broken.zip', { type: 'application/zip' }));
+ await user.click(await screen.findByText('Yes, restore'));
- expect(await screen.findByText('not a backup')).toBeInTheDocument()
- await waitFor(() => expect(screen.getByTitle('Upload Backup')).toBeEnabled())
- })
+ expect(await screen.findByText('not a backup')).toBeInTheDocument();
+ await waitFor(() => expect(screen.getByTitle('Upload Backup')).toBeEnabled());
+ });
// BKP-020: Upload button forwards the click to the hidden file input
it('FE-ADMIN-BKP-020: the Upload button opens the hidden file picker', async () => {
- const user = userEvent.setup()
- render( )
- await screen.findByText('backup-2025-01-15.zip')
+ const user = userEvent.setup();
+ render( );
+ await screen.findByText('backup-2025-01-15.zip');
- const input = document.querySelector('input[type="file"]') as HTMLInputElement
- const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
- await user.click(screen.getByTitle('Upload Backup'))
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+ const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {});
+ await user.click(screen.getByTitle('Upload Backup'));
- expect(clickSpy).toHaveBeenCalled()
- })
+ expect(clickSpy).toHaveBeenCalled();
+ });
// BKP-021: Delete declined / failing
it('FE-ADMIN-BKP-021: declining the confirm keeps the backup, a failing delete toasts', async () => {
- const user = userEvent.setup()
- let deleteCalls = 0
+ const user = userEvent.setup();
+ let deleteCalls = 0;
server.use(
http.delete('/api/backup/:filename', () => {
- deleteCalls += 1
- return HttpResponse.json({ error: 'file is locked' }, { status: 500 })
- }),
- )
- render(<> >)
- await screen.findByText('backup-2025-01-15.zip')
+ deleteCalls += 1;
+ return HttpResponse.json({ error: 'file is locked' }, { status: 500 });
+ })
+ );
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('backup-2025-01-15.zip');
- const trashBtn = Array.from(document.querySelectorAll('button')).find(
- 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()
+ const trashBtn = Array.from(document.querySelectorAll('button')).find((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();
- await user.click(trashBtn)
- const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' })
- await user.click(deleteButtons[deleteButtons.length - 1]!)
+ 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()
- })
+ expect(await screen.findByText('Failed to delete')).toBeInTheDocument();
+ expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
+ });
// BKP-022: Auto settings save fails
it('FE-ADMIN-BKP-022: a failing auto-settings save toasts and keeps the form dirty', async () => {
- const user = userEvent.setup()
- server.use(http.put('/api/backup/auto-settings', () => HttpResponse.error()))
- render(<> >)
- await screen.findByText('Enable auto-backup')
+ const user = userEvent.setup();
+ server.use(http.put('/api/backup/auto-settings', () => HttpResponse.error()));
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('Enable auto-backup');
- await user.click(getToggleButton())
- await user.click(screen.getByRole('button', { name: /^save$/i }))
+ await user.click(getToggleButton());
+ await user.click(screen.getByRole('button', { name: /^save$/i }));
- expect(await screen.findByText('Failed to save settings')).toBeInTheDocument()
- await waitFor(() => expect(screen.getByRole('button', { name: /^save$/i })).toBeEnabled())
- })
+ expect(await screen.findByText('Failed to save settings')).toBeInTheDocument();
+ await waitFor(() => expect(screen.getByRole('button', { name: /^save$/i })).toBeEnabled());
+ });
// BKP-023: Size/date fallbacks
it('FE-ADMIN-BKP-023: missing size and date render as a dash, kilobytes are formatted', async () => {
@@ -467,15 +514,15 @@ describe('BackupPanel', () => {
{ filename: 'empty.zip', created_at: null, size: 0 },
{ filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 5120 },
],
- }),
- ),
- )
- render( )
- await screen.findByText('empty.zip')
+ })
+ )
+ );
+ render( );
+ await screen.findByText('empty.zip');
- expect(screen.getAllByText('-')).toHaveLength(2)
- expect(screen.getByText('5.0 KB')).toBeInTheDocument()
- })
+ expect(screen.getAllByText('-')).toHaveLength(2);
+ expect(screen.getByText('5.0 KB')).toBeInTheDocument();
+ });
// BKP-024: Invalid server timezone
it('FE-ADMIN-BKP-024: an unusable server timezone falls back to the raw timestamp', async () => {
@@ -484,129 +531,149 @@ describe('BackupPanel', () => {
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'Not/AZone',
- }),
- ),
- )
- render( )
- await screen.findByText('backup-2025-01-15.zip')
+ })
+ )
+ );
+ render( );
+ await screen.findByText('backup-2025-01-15.zip');
- await waitFor(() => expect(screen.getByText('2025-01-15T10:00:00Z')).toBeInTheDocument())
- })
+ await waitFor(() => expect(screen.getByText('2025-01-15T10:00:00Z')).toBeInTheDocument());
+ });
// BKP-025: 12h hour picker
it('FE-ADMIN-BKP-025: the hour picker uses AM/PM labels for 12h users and stores the pick', async () => {
- const user = userEvent.setup()
- seedStore(useSettingsStore, { settings: { time_format: '12h' } } as any)
- let saved: Record | null = null
+ const user = userEvent.setup();
+ seedStore(useSettingsStore, { settings: { time_format: '12h' } } as any);
+ let saved: Record | null = null;
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 0, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
- }),
+ })
),
http.put('/api/backup/auto-settings', async ({ request }) => {
- saved = await request.json() as Record
- return HttpResponse.json({ settings: saved })
- }),
- )
- render(<> >)
- await screen.findByText('Run at hour')
+ saved = (await request.json()) as Record;
+ return HttpResponse.json({ settings: saved });
+ })
+ );
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('Run at hour');
- expect(screen.getByText('Server local time (12h format) (Timezone: UTC)')).toBeInTheDocument()
- await user.click(screen.getByRole('button', { name: '12:00 AM' }))
- await user.click(await screen.findByRole('button', { name: '2:00 PM' }))
- await user.click(screen.getByRole('button', { name: /^save$/i }))
+ expect(screen.getByText('Server local time (12h format) (Timezone: UTC)')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: '12:00 AM' }));
+ await user.click(await screen.findByRole('button', { name: '2:00 PM' }));
+ await user.click(screen.getByRole('button', { name: /^save$/i }));
- await waitFor(() => expect(saved).toMatchObject({ hour: 14 }))
- })
+ await waitFor(() => expect(saved).toMatchObject({ hour: 14 }));
+ });
// BKP-026: Monthly interval
it('FE-ADMIN-BKP-026: the monthly interval offers a day-of-month picker', async () => {
- const user = userEvent.setup()
- let saved: Record | null = null
+ const user = userEvent.setup();
+ let saved: Record | null = null;
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'monthly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: '',
- }),
+ })
),
http.put('/api/backup/auto-settings', async ({ request }) => {
- saved = await request.json() as Record
- return HttpResponse.json({ settings: saved })
- }),
- )
- render(<> >)
- await screen.findByText('Day of month')
+ saved = (await request.json()) as Record;
+ return HttpResponse.json({ settings: saved });
+ })
+ );
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('Day of month');
// No timezone from the server → the hint carries no timezone suffix
- expect(screen.getByText('Server local time (24h format)')).toBeInTheDocument()
- expect(screen.queryByText('Sun')).not.toBeInTheDocument()
+ expect(screen.getByText('Server local time (24h format)')).toBeInTheDocument();
+ expect(screen.queryByText('Sun')).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: '1' }))
- await user.click(await screen.findByRole('button', { name: '15' }))
- await user.click(screen.getByRole('button', { name: /^save$/i }))
+ await user.click(screen.getByRole('button', { name: '1' }));
+ await user.click(await screen.findByRole('button', { name: '15' }));
+ await user.click(screen.getByRole('button', { name: /^save$/i }));
- await waitFor(() => expect(saved).toMatchObject({ day_of_month: 15 }))
- })
+ await waitFor(() => expect(saved).toMatchObject({ day_of_month: 15 }));
+ });
// BKP-027: Day of week + retention
it('FE-ADMIN-BKP-027: day-of-week and retention picks are stored together', async () => {
- const user = userEvent.setup()
- let saved: Record | null = null
+ const user = userEvent.setup();
+ let saved: Record | null = null;
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
- }),
+ })
),
http.put('/api/backup/auto-settings', async ({ request }) => {
- saved = await request.json() as Record
- return HttpResponse.json({ settings: saved })
- }),
- )
- render(<> >)
- await screen.findByText('Day of week')
+ saved = (await request.json()) as Record;
+ return HttpResponse.json({ settings: saved });
+ })
+ );
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('Day of week');
- await user.click(screen.getByText('Fri'))
- await user.click(screen.getByText('Keep forever'))
- await user.click(screen.getByRole('button', { name: /^save$/i }))
+ await user.click(screen.getByText('Fri'));
+ await user.click(screen.getByText('Keep forever'));
+ await user.click(screen.getByRole('button', { name: /^save$/i }));
- await waitFor(() => expect(saved).toMatchObject({ day_of_week: 5, keep_days: 0 }))
- })
+ await waitFor(() => expect(saved).toMatchObject({ day_of_week: 5, keep_days: 0 }));
+ });
// BKP-028: Download failure
it('FE-ADMIN-BKP-028: a failing download toasts the download error', async () => {
- const user = userEvent.setup()
- server.use(http.get('/api/backup/download/:filename', () => HttpResponse.error()))
- render(<> >)
- await screen.findByText('backup-2025-01-15.zip')
+ const user = userEvent.setup();
+ server.use(http.get('/api/backup/download/:filename', () => HttpResponse.error()));
+ render(
+ <>
+
+
+ >
+ );
+ await screen.findByText('backup-2025-01-15.zip');
- await user.click(screen.getByText('Download'))
+ await user.click(screen.getByText('Download'));
- expect(await screen.findByText('Download failed')).toBeInTheDocument()
- })
+ expect(await screen.findByText('Download failed')).toBeInTheDocument();
+ });
// 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
+ 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')
+ restoreCalled = true;
+ return HttpResponse.json({ success: true });
+ })
+ );
+ render( );
+ await screen.findByText('backup-2025-01-15.zip');
- await user.click(screen.getAllByText('Restore')[0])
- await screen.findByText('Yes, restore')
+ await user.click(screen.getAllByText('Restore')[0]);
+ await screen.findByText('Yes, restore');
- await user.keyboard('{Escape}')
- await waitFor(() => expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument())
- expect(restoreCalled).toBe(false)
- })
-})
+ 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 bfca2f5ff..ee20dc23e 100644
--- a/client/src/components/Admin/BackupPanel.tsx
+++ b/client/src/components/Admin/BackupPanel.tsx
@@ -1,16 +1,10 @@
-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, 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 { Check, Clock, Download, HardDrive, Loader2, Plus, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react';
+import { useEffect, useRef, useState, type ChangeEvent } from 'react';
+import { backupApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { useSettingsStore } from '../../store/settingsStore';
+import { getApiErrorMessage } from '../../types';
+import ToggleSwitch from '../Settings/ToggleSwitch';
import {
ChoiceChips,
SETTINGS_BUTTON,
@@ -20,24 +14,30 @@ import {
SettingRows,
SettingsCard,
StatusPill,
-} from '../Settings/settingsKit'
-import { getApiErrorMessage } from '../../types'
+} from '../Settings/settingsKit';
+import ConfirmDialog from '../shared/ConfirmDialog';
+import CustomSelect from '../shared/CustomSelect';
+import { fs } from '../shared/DialogShell';
+import EmptyState from '../shared/EmptyState';
+import { useToast } from '../shared/Toast';
+import { Tooltip } from '../shared/Tooltip';
+import { Segmented } from '../shared/dialogParts';
const INTERVAL_OPTIONS = [
- { value: 'hourly', labelKey: 'backup.interval.hourly' },
- { value: 'daily', labelKey: 'backup.interval.daily' },
- { value: 'weekly', labelKey: 'backup.interval.weekly' },
+ { value: 'hourly', labelKey: 'backup.interval.hourly' },
+ { value: 'daily', labelKey: 'backup.interval.daily' },
+ { value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
-]
+];
const KEEP_OPTIONS = [
- { value: 1, labelKey: 'backup.keep.1day' },
- { value: 3, labelKey: 'backup.keep.3days' },
- { value: 7, labelKey: 'backup.keep.7days' },
+ { value: 1, labelKey: 'backup.keep.1day' },
+ { value: 3, labelKey: 'backup.keep.3days' },
+ { value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
- { value: 0, labelKey: 'backup.keep.forever' },
-]
+ { value: 0, labelKey: 'backup.keep.forever' },
+];
const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
@@ -47,179 +47,196 @@ const DAYS_OF_WEEK = [
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
-]
+];
-const HOURS = Array.from({ length: 24 }, (_, i) => i)
+const HOURS = Array.from({ length: 24 }, (_, i) => i);
-const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
+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'
+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
+ filename: string;
+ created_at?: string | null;
+ size?: number | null;
}
interface RestoreTarget {
- type: 'file' | 'upload'
- filename: string
- file?: File
+ type: 'file' | 'upload';
+ filename: string;
+ file?: File;
}
export default function BackupPanel() {
- const [backups, setBackups] = useState([])
- const [isLoading, setIsLoading] = useState(false)
- const [isCreating, setIsCreating] = useState(false)
- 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)
- const [deleteTarget, setDeleteTarget] = useState(null)
- const fileInputRef = useRef(null)
- const toast = useToast()
- const { t, locale } = useTranslation()
- const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ const [backups, setBackups] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ 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);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const fileInputRef = useRef(null);
+ const toast = useToast();
+ const { t, locale } = useTranslation();
+ const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
- setIsLoading(true)
+ setIsLoading(true);
try {
- const data = await backupApi.list()
- setBackups(data.backups || [])
+ const data = await backupApi.list();
+ setBackups(data.backups || []);
} catch {
- toast.error(t('backup.toast.loadError'))
+ toast.error(t('backup.toast.loadError'));
} finally {
- setIsLoading(false)
+ setIsLoading(false);
}
- }
+ };
const loadAutoSettings = async () => {
try {
- const data = await backupApi.getAutoSettings()
- setAutoSettings(data.settings)
- if (data.timezone) setServerTimezone(data.timezone)
+ const data = await backupApi.getAutoSettings();
+ setAutoSettings(data.settings);
+ if (data.timezone) setServerTimezone(data.timezone);
} catch {}
- }
+ };
- useEffect(() => { void loadBackups(); void loadAutoSettings() }, [])
+ useEffect(() => {
+ void loadBackups();
+ void loadAutoSettings();
+ }, []);
const handleCreate = async () => {
- setIsCreating(true)
+ setIsCreating(true);
try {
- await backupApi.create()
- toast.success(t('backup.toast.created'))
- await loadBackups()
+ await backupApi.create();
+ toast.success(t('backup.toast.created'));
+ await loadBackups();
} catch {
- toast.error(t('backup.toast.createError'))
+ toast.error(t('backup.toast.createError'));
} finally {
- setIsCreating(false)
+ setIsCreating(false);
}
- }
+ };
const handleRestore = (filename: string) => {
- setRestoreConfirm({ type: 'file', filename })
- }
+ setRestoreConfirm({ type: 'file', filename });
+ };
const handleUploadRestore = (e: ChangeEvent) => {
- const file = e.target.files?.[0]
- if (!file) return
- e.target.value = ''
- setRestoreConfirm({ type: 'upload', filename: file.name, file })
- }
+ const file = e.target.files?.[0];
+ if (!file) return;
+ e.target.value = '';
+ setRestoreConfirm({ type: 'upload', filename: file.name, file });
+ };
const executeRestore = async () => {
- if (!restoreConfirm) return
- const { type, filename, file } = restoreConfirm
- setRestoreConfirm(null)
+ if (!restoreConfirm) return;
+ const { type, filename, file } = restoreConfirm;
+ setRestoreConfirm(null);
if (type === 'file') {
- setRestoringFile(filename)
+ setRestoringFile(filename);
try {
- await backupApi.restore(filename)
- toast.success(t('backup.toast.restored'))
- setTimeout(() => window.location.reload(), 1500)
+ await backupApi.restore(filename);
+ toast.success(t('backup.toast.restored'));
+ setTimeout(() => window.location.reload(), 1500);
} catch (err: unknown) {
- toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
- setRestoringFile(null)
+ toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')));
+ setRestoringFile(null);
}
} else {
- setIsUploading(true)
+ setIsUploading(true);
try {
- await backupApi.uploadRestore(file)
- toast.success(t('backup.toast.restored'))
- setTimeout(() => window.location.reload(), 1500)
+ await backupApi.uploadRestore(file);
+ toast.success(t('backup.toast.restored'));
+ setTimeout(() => window.location.reload(), 1500);
} catch (err: unknown) {
- toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
- setIsUploading(false)
+ toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')));
+ setIsUploading(false);
}
}
- }
+ };
// The question is asked in the planner's confirm dialog; the delete itself runs once it is answered.
const handleDelete = (filename: string) => {
- setDeleteTarget(filename)
- }
+ setDeleteTarget(filename);
+ };
const executeDelete = async () => {
- const filename = deleteTarget
- setDeleteTarget(null)
- if (!filename) return
+ const filename = deleteTarget;
+ setDeleteTarget(null);
+ if (!filename) return;
try {
- await backupApi.delete(filename)
- toast.success(t('backup.toast.deleted'))
- setBackups(prev => prev.filter(b => b.filename !== filename))
+ await backupApi.delete(filename);
+ toast.success(t('backup.toast.deleted'));
+ setBackups((prev) => prev.filter((b) => b.filename !== filename));
} catch {
- toast.error(t('backup.toast.deleteError'))
+ toast.error(t('backup.toast.deleteError'));
}
- }
+ };
const handleAutoSettingsChange = (key: string, value: unknown) => {
- setAutoSettings(prev => ({ ...prev, [key]: value }))
- setAutoSettingsDirty(true)
- }
+ setAutoSettings((prev) => ({ ...prev, [key]: value }));
+ setAutoSettingsDirty(true);
+ };
const handleSaveAutoSettings = async () => {
- setAutoSettingsSaving(true)
+ setAutoSettingsSaving(true);
try {
- const data = await backupApi.setAutoSettings(autoSettings)
- setAutoSettings(data.settings)
- setAutoSettingsDirty(false)
- toast.success(t('backup.toast.settingsSaved'))
+ const data = await backupApi.setAutoSettings(autoSettings);
+ setAutoSettings(data.settings);
+ setAutoSettingsDirty(false);
+ toast.success(t('backup.toast.settingsSaved'));
} catch {
- toast.error(t('backup.toast.settingsError'))
+ toast.error(t('backup.toast.settingsError'));
} finally {
- setAutoSettingsSaving(false)
+ setAutoSettingsSaving(false);
}
- }
+ };
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`
- }
+ if (!bytes) return '-';
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+ };
const formatDate = (dateStr: string | null | undefined) => {
- if (!dateStr) return '-'
+ if (!dateStr) return '-';
try {
const opts: Intl.DateTimeFormatOptions = {
- day: '2-digit', month: '2-digit', year: 'numeric',
- hour: '2-digit', minute: '2-digit',
- }
- if (serverTimezone) opts.timeZone = serverTimezone
- return new Date(dateStr).toLocaleString(locale, opts)
- } catch { return dateStr }
- }
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ };
+ if (serverTimezone) opts.timeZone = serverTimezone;
+ return new Date(dateStr).toLocaleString(locale, opts);
+ } catch {
+ return dateStr;
+ }
+ };
- const isAuto = (filename: string) => filename.startsWith('auto-backup-')
+ const isAuto = (filename: string) => filename.startsWith('auto-backup-');
const headerActions = (
<>
-
{/* Upload & Restore */}
-
-
+ fileInputRef.current?.click()}
disabled={isUploading}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
title={isUploading ? t('backup.uploading') : t('backup.upload')}
>
- {isUploading
- ?
- : }
+ {isUploading ? (
+
+ ) : (
+
+ )}
{isUploading ? t('backup.uploading') : t('backup.upload')}
-
- {isCreating
- ?
- : }
+ {isCreating ? (
+
+ ) : (
+
+ )}
{isCreating ? t('backup.creating') : t('backup.create')}
>
- )
+ );
- let backupList
+ let backupList;
if (isLoading && backups.length === 0) {
backupList = (
{t('common.loading')}
- )
+ );
} else if (backups.length === 0) {
backupList = (
}
/>
- )
+ );
} else {
backupList = (
- {backups.map(backup => {
- const auto = isAuto(backup.filename)
- const restoring = restoringFile === backup.filename
+ {backups.map((backup) => {
+ const auto = isAuto(backup.filename);
+ const restoring = restoringFile === backup.filename;
return (
- {auto
- ?
- : }
+ {auto ? : }
-
{backup.filename}
+
+ {backup.filename}
+
{auto &&
Auto }
-
+
{formatDate(backup.created_at)}
·
{formatSize(backup.size)}
- backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
+
+ backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))
+ }
className={ROW_BUTTON}
style={fs(12, 'body')}
>
{t('backup.download')}
- handleRestore(backup.filename)}
disabled={restoring}
className={ROW_BUTTON}
style={fs(12, 'body')}
>
- {restoring
- ?
- : }
+ {restoring ? (
+
+ ) : (
+
+ )}
{t('backup.restore')}
- handleDelete(backup.filename)}
aria-label={t('common.delete')}
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
@@ -344,34 +371,28 @@ export default function BackupPanel() {
- )
+ );
})}
- )
+ );
}
- const hourOptions = HOURS.map(h => {
- let label: string
+ 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}`
+ 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`
+ label = `${String(h).padStart(2, '0')}:00`;
}
- return { value: String(h), label }
- })
+ return { value: String(h), label };
+ });
return (
-
{/* Manual Backups */}
-
+
{backupList}
@@ -381,8 +402,12 @@ export default function BackupPanel() {
{/* The whole row is the switch's label, so a click on the text flips it too. */}
- {t('backup.auto.enable')}
- {t('backup.auto.enableHint')}
+
+ {t('backup.auto.enable')}
+
+
+ {t('backup.auto.enableHint')}
+
label={t('backup.auto.interval')}
value={autoSettings.interval}
- onChange={v => handleAutoSettingsChange('interval', v)}
- options={INTERVAL_OPTIONS.map(opt => ({ value: opt.value, label: t(opt.labelKey) }))}
+ onChange={(v) => handleAutoSettingsChange('interval', v)}
+ options={INTERVAL_OPTIONS.map((opt) => ({ value: opt.value, label: t(opt.labelKey) }))}
/>
}
/>
@@ -409,12 +434,17 @@ export default function BackupPanel() {
{autoSettings.interval !== 'hourly' && (
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}>}
+ hint={
+ <>
+ {t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}
+ {serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
+ >
+ }
control={
handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
+ onChange={(v) => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
size="sm"
options={hourOptions}
/>
@@ -432,8 +462,8 @@ export default function BackupPanel() {
label={t('backup.auto.dayOfWeek')}
value={String(autoSettings.day_of_week)}
- onChange={v => handleAutoSettingsChange('day_of_week', Number(v))}
- options={DAYS_OF_WEEK.map(opt => ({ value: String(opt.value), label: t(opt.labelKey) }))}
+ onChange={(v) => handleAutoSettingsChange('day_of_week', Number(v))}
+ options={DAYS_OF_WEEK.map((opt) => ({ value: String(opt.value), label: t(opt.labelKey) }))}
/>
}
/>
@@ -448,9 +478,9 @@ export default function BackupPanel() {
handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
+ onChange={(v) => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
size="sm"
- options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
+ options={DAYS_OF_MONTH.map((d) => ({ value: String(d), label: String(d) }))}
/>
}
@@ -465,8 +495,8 @@ export default function BackupPanel() {
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) }))}
+ onChange={(v) => handleAutoSettingsChange('keep_days', Number(v))}
+ options={KEEP_OPTIONS.map((opt) => ({ value: String(opt.value), label: t(opt.labelKey) }))}
/>
}
/>
@@ -476,15 +506,18 @@ export default function BackupPanel() {
{/* Save button */}
-
- {autoSettingsSaving
- ?
- : }
+ {autoSettingsSaving ? (
+
+ ) : (
+
+ )}
{autoSettingsSaving ? t('common.saving') : t('common.save')}
@@ -504,9 +537,14 @@ export default function BackupPanel() {
<>
- {restoreConfirm.filename}
+
+ {restoreConfirm.filename}
+
-
+
{t('backup.restoreTip')}
>
@@ -524,5 +562,5 @@ export default function BackupPanel() {
danger
/>
- )
+ );
}
diff --git a/client/src/components/Admin/CategoryManager.test.tsx b/client/src/components/Admin/CategoryManager.test.tsx
index 4160fd1fb..a38cbaf3c 100644
--- a/client/src/components/Admin/CategoryManager.test.tsx
+++ b/client/src/components/Admin/CategoryManager.test.tsx
@@ -1,21 +1,17 @@
// FE-COMP-CAT-001 to FE-COMP-CAT-020
-import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
+import { buildCategory, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
-import { useAuthStore } from '../../store/authStore';
+import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
-import { buildUser, buildCategory } from '../../../tests/helpers/factories';
-import CategoryManager from './CategoryManager';
+import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
+import CategoryManager from './CategoryManager';
beforeEach(() => {
resetAllStores();
- server.use(
- http.get('/api/categories', () =>
- HttpResponse.json({ categories: [] })
- ),
- );
+ server.use(http.get('/api/categories', () => HttpResponse.json({ categories: [] })));
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
});
@@ -59,10 +55,7 @@ describe('CategoryManager', () => {
server.use(
http.get('/api/categories', () =>
HttpResponse.json({
- categories: [
- buildCategory({ name: 'Museum' }),
- buildCategory({ name: 'Restaurant' }),
- ],
+ categories: [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Restaurant' })],
})
)
);
@@ -77,13 +70,18 @@ describe('CategoryManager', () => {
server.use(
http.post('/api/categories', async ({ request }) => {
postCalled = true;
- const body = await request.json() as Record;
+ const body = (await request.json()) as Record;
return HttpResponse.json({
category: buildCategory({ name: String(body.name) }),
});
})
);
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
const nameInput = screen.getByPlaceholderText('Category name');
@@ -95,9 +93,7 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/categories', () =>
- HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] })
- )
+ http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] }))
);
render( );
await screen.findByText('Hotels');
@@ -105,7 +101,7 @@ describe('CategoryManager', () => {
const buttons = screen.getAllByRole('button');
// Buttons: [New Category, ...action buttons for the category]
// The edit button is the first action button in the category row (Edit2 icon)
- const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
+ const actionBtns = buttons.filter((b) => !b.textContent?.includes('New Category'));
await user.click(actionBtns[0]);
// Name input pre-filled with category name
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
@@ -115,19 +111,24 @@ describe('CategoryManager', () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
- http.get('/api/categories', () =>
- HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })
- ),
+ http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Parks');
await user.click(screen.getByRole('button', { name: 'Delete' }));
// Nothing goes before the confirm dialog is answered
- expect(await screen.findByText('Delete category? Places in this category will not be deleted.')).toBeInTheDocument();
+ expect(
+ await screen.findByText('Delete category? Places in this category will not be deleted.')
+ ).toBeInTheDocument();
expect(deleteCalled).toBe(false);
await confirmDelete(user);
await waitFor(() => expect(deleteCalled).toBe(true));
@@ -166,7 +167,12 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-013: a failing list request toasts and falls back to the empty state', async () => {
server.use(http.get('/api/categories', () => HttpResponse.error()));
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
expect(await screen.findByText('Failed to load categories')).toBeInTheDocument();
expect(screen.getByText('No categories yet')).toBeInTheDocument();
@@ -180,14 +186,21 @@ describe('CategoryManager', () => {
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels', color: '#6366f1', icon: 'MapPin' })] })
),
http.put('/api/categories/5', async ({ request }) => {
- body = await request.json() as Record;
- return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }) });
- }),
+ body = (await request.json()) as Record;
+ return HttpResponse.json({
+ category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }),
+ });
+ })
+ );
+ render(
+ <>
+
+
+ >
);
- render(<> >);
await screen.findByText('Hotels');
- await user.click(screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'))[0]);
+ await user.click(screen.getAllByRole('button').filter((b) => !b.textContent?.includes('New Category'))[0]);
const nameInput = screen.getByDisplayValue('Hotels');
await user.clear(nameInput);
await user.type(nameInput, 'Lodging');
@@ -201,10 +214,13 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-015: a failing save surfaces the server message', async () => {
const user = userEvent.setup();
- server.use(
- http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })),
+ server.use(http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })));
+ render(
+ <>
+
+
+ >
);
- render(<> >);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
@@ -221,7 +237,10 @@ describe('CategoryManager', () => {
let deleteCalled = false;
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
- http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }),
+ http.delete('/api/categories/9', () => {
+ deleteCalled = true;
+ return HttpResponse.json({ success: true });
+ })
);
render( );
await screen.findByText('Parks');
@@ -239,9 +258,14 @@ describe('CategoryManager', () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
- http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })),
+ http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 }))
+ );
+ render(
+ <>
+
+
+ >
);
- render(<> >);
await screen.findByText('Parks');
await user.click(screen.getByRole('button', { name: 'Delete' }));
@@ -290,7 +314,7 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-020: starting an edit closes the create form', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] })),
+ http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] }))
);
render( );
await screen.findByText('Hotels');
diff --git a/client/src/components/Admin/CategoryManager.tsx b/client/src/components/Admin/CategoryManager.tsx
index 9966aef92..ab634c82e 100644
--- a/client/src/components/Admin/CategoryManager.tsx
+++ b/client/src/components/Admin/CategoryManager.tsx
@@ -1,108 +1,136 @@
-import { useState, useEffect, useRef, useId } from 'react'
-import { categoriesApi } from '../../api/client'
-import { useToast } from '../shared/Toast'
-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 ConfirmDialog from '../shared/ConfirmDialog'
-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'
+import { Loader2, Pencil, Pipette, Plus, Tags, Trash2 } from 'lucide-react';
+import { useEffect, useId, useRef, useState } from 'react';
+import { categoriesApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { getApiErrorMessage } from '../../types';
+import {
+ SETTINGS_BUTTON_PRIMARY,
+ SETTINGS_ICON_BUTTON,
+ SettingRows,
+ SettingsCard,
+ SettingsHint,
+ StatusPill,
+} from '../Settings/settingsKit';
+import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons';
+import ConfirmDialog from '../shared/ConfirmDialog';
+import {
+ DialogButton,
+ DialogFooter,
+ DialogHeader,
+ DialogSection,
+ DialogShell,
+ DialogTile,
+ FooterSpacer,
+ NEUTRAL_TINT,
+ fs,
+} from '../shared/DialogShell';
+import { useToast } from '../shared/Toast';
+import { Tooltip } from '../shared/Tooltip';
const PRESET_COLORS = [
- '#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
- '#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
- '#6b7280', '#1f2937',
-]
+ '#6366f1',
+ '#8b5cf6',
+ '#ec4899',
+ '#ef4444',
+ '#f97316',
+ '#f59e0b',
+ '#10b981',
+ '#06b6d4',
+ '#3b82f6',
+ '#84cc16',
+ '#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 DEFAULT_COLOR = '#6366f1'; // theme-lint-disable: a stored category colour, not styling
-const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
+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: DEFAULT_COLOR, icon: 'MapPin' })
- const [isSaving, setIsSaving] = useState(false)
- const [isLoading, setIsLoading] = useState(true)
+ const [categories, setCategories] = useState([]);
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
+ const [isSaving, setIsSaving] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
// The category whose delete waits for the answer in the confirm dialog.
- const [deleteId, setDeleteId] = useState(null)
- const colorInputRef = useRef(null)
- const toast = useToast()
- const { t } = useTranslation()
- const labelId = useId()
+ const [deleteId, setDeleteId] = useState(null);
+ const colorInputRef = useRef(null);
+ const toast = useToast();
+ const { t } = useTranslation();
+ const labelId = useId();
- useEffect(() => { void loadCategories() }, [])
+ useEffect(() => {
+ void loadCategories();
+ }, []);
const loadCategories = async () => {
- setIsLoading(true)
+ setIsLoading(true);
try {
- const data = await categoriesApi.list()
- setCategories(data.categories || [])
+ const data = await categoriesApi.list();
+ setCategories(data.categories || []);
} catch (err: unknown) {
- toast.error(t('categories.toast.loadError'))
+ toast.error(t('categories.toast.loadError'));
} finally {
- setIsLoading(false)
+ setIsLoading(false);
}
- }
+ };
const handleStartEdit = (cat) => {
- setEditingId(cat.id)
- setForm({ name: cat.name, color: cat.color || DEFAULT_COLOR, icon: cat.icon || 'MapPin' })
- setShowForm(false)
- }
+ setEditingId(cat.id);
+ setForm({ name: cat.name, color: cat.color || DEFAULT_COLOR, icon: cat.icon || 'MapPin' });
+ setShowForm(false);
+ };
const handleStartCreate = () => {
- setEditingId(null)
- setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
- setShowForm(true)
- }
+ setEditingId(null);
+ setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
+ setShowForm(true);
+ };
const handleCancel = () => {
- setShowForm(false)
- setEditingId(null)
- }
+ setShowForm(false);
+ setEditingId(null);
+ };
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
const handleSave = async () => {
- setIsSaving(true)
+ setIsSaving(true);
try {
if (editingId) {
- const result = await categoriesApi.update(editingId, form)
- setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
- setEditingId(null)
- toast.success(t('categories.toast.updated'))
+ const result = await categoriesApi.update(editingId, form);
+ setCategories((prev) => prev.map((c) => (c.id === editingId ? result.category : c)));
+ setEditingId(null);
+ toast.success(t('categories.toast.updated'));
} else {
- const result = await categoriesApi.create(form)
- setCategories(prev => [...prev, result.category])
- setShowForm(false)
- toast.success(t('categories.toast.created'))
+ const result = await categoriesApi.create(form);
+ setCategories((prev) => [...prev, result.category]);
+ setShowForm(false);
+ toast.success(t('categories.toast.created'));
}
- setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
+ setForm({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' });
} catch (err: unknown) {
- toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
+ toast.error(getApiErrorMessage(err, t('categories.toast.saveError')));
} finally {
- setIsSaving(false)
+ setIsSaving(false);
}
- }
+ };
const handleDelete = async (id) => {
try {
- await categoriesApi.delete(id)
- setCategories(prev => prev.filter(c => c.id !== id))
- toast.success(t('categories.toast.deleted'))
+ await categoriesApi.delete(id);
+ setCategories((prev) => prev.filter((c) => c.id !== id));
+ toast.success(t('categories.toast.deleted'));
} catch (err: unknown) {
- toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
+ toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')));
}
- }
+ };
- const isPresetColor = PRESET_COLORS.includes(form.color)
- const PreviewIcon = getCategoryIcon(form.icon)
- const formOpen = showForm || editingId !== null
- const saveDisabled = isSaving || !form.name.trim()
+ const isPresetColor = PRESET_COLORS.includes(form.color);
+ const PreviewIcon = getCategoryIcon(form.icon);
+ const formOpen = showForm || editingId !== null;
+ const saveDisabled = isSaving || !form.name.trim();
const categoryDialog = (
}
+ tile={
+
+
+
+ }
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 })),
+ 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() },
+ onKeyDown: (e) => {
+ if (e.key === 'Enter' && !saveDisabled) void handleSave();
+ },
}}
/>
}
@@ -139,9 +173,9 @@ export default function CategoryManager() {
- {ICON_NAMES.map(name => {
- const Icon = CATEGORY_ICON_MAP[name]
- const isSelected = form.icon === name
+ {ICON_NAMES.map((name) => {
+ const Icon = CATEGORY_ICON_MAP[name];
+ const isSelected = form.icon === name;
return (
setForm(prev => ({ ...prev, icon: name }))}
+ onClick={() => setForm((prev) => ({ ...prev, icon: name }))}
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'
+ isSelected
+ ? 'bg-surface-card shadow-sm ring-2 ring-content'
+ : 'text-content-secondary hover:bg-surface-card'
}`}
style={isSelected ? { background: `${form.color}18` } : undefined}
>
- )
+ );
})}
@@ -165,12 +201,16 @@ export default function CategoryManager() {
- {PRESET_COLORS.map(color => (
-
setForm(prev => ({ ...prev, color }))}
+ {PRESET_COLORS.map((color) => (
+ setForm((prev) => ({ ...prev, color }))}
aria-label={color}
aria-pressed={form.color === color}
className={`h-7 w-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'scale-110 ring-2 ring-content ring-offset-2 ring-offset-surface-card' : ''}`}
- style={{ backgroundColor: color }} />
+ style={{ backgroundColor: color }}
+ />
))}
{/* Custom color button */}
@@ -178,7 +218,7 @@ export default function CategoryManager() {
ref={colorInputRef}
type="color"
value={form.color}
- onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
+ onChange={(e) => setForm((prev) => ({ ...prev, color: e.target.value }))}
className="sr-only"
tabIndex={-1}
aria-hidden
@@ -202,15 +242,17 @@ export default function CategoryManager() {
-
+
{form.name || t('categories.defaultName')}
- )
+ );
return (
) : (
- {categories.map(cat => {
- const Icon = getCategoryIcon(cat.icon)
+ {categories.map((cat) => {
+ const Icon = getCategoryIcon(cat.icon);
return (
-
-
+
+
- {cat.name}
-
+
+ {cat.name}
+
+
{cat.color}
- handleStartEdit(cat)} aria-label={t('common.edit')} className={SETTINGS_ICON_BUTTON}>
+ handleStartEdit(cat)}
+ aria-label={t('common.edit')}
+ className={SETTINGS_ICON_BUTTON}
+ >
- setDeleteId(cat.id)} aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}>
+ setDeleteId(cat.id)}
+ aria-label={t('common.delete')}
+ className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
+ >
- )
+ );
})}
)}
@@ -271,11 +333,13 @@ export default function CategoryManager() {
setDeleteId(null)}
- onConfirm={() => { if (deleteId !== null) void handleDelete(deleteId) }}
+ onConfirm={() => {
+ if (deleteId !== null) void handleDelete(deleteId);
+ }}
message={t('categories.confirm.delete')}
confirmLabel={t('common.delete')}
danger
/>
- )
+ );
}
diff --git a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx
index e52b0160b..f1db15291 100644
--- a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx
+++ b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx
@@ -1,10 +1,10 @@
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-028
-import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
-import { server } from '../../../tests/helpers/msw/server';
-import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildAdmin } from '../../../tests/helpers/factories';
+import { server } from '../../../tests/helpers/msw/server';
+import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
+import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import DefaultUserSettingsTab from './DefaultUserSettingsTab';
@@ -27,20 +27,25 @@ function stubDefaults(initial: Record = {}) {
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)),
http.put('/api/admin/default-user-settings', async ({ request }) => {
- const body = await request.json() as Record;
+ const body = (await request.json()) as Record;
puts.push(body);
for (const [key, value] of Object.entries(body)) {
if (value === null) delete state[key];
else state[key] = value;
}
return HttpResponse.json({ ...state });
- }),
+ })
);
return { puts, state };
}
function withToast() {
- return render(<> >);
+ return render(
+ <>
+
+
+ >
+ );
}
/** The selected option is the pressed button of its segmented group. */
@@ -53,13 +58,13 @@ function isActive(button: HTMLElement): boolean {
* element the wrapping label becomes its accessible name, so it is queried positionally.
*/
function resetLink(label: string): HTMLElement {
- const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
+ const el = screen.getAllByText(label).find((node) => node.tagName === 'LABEL');
if (!el) throw new Error(`no label found for ${label}`);
return within(el).getByRole('button');
}
function hasResetLink(label: string): boolean {
- const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
+ const el = screen.getAllByText(label).find((node) => node.tagName === 'LABEL');
return !!el && within(el).queryByRole('button') !== null;
}
@@ -97,7 +102,14 @@ describe('DefaultUserSettingsTab', () => {
for (const name of ['Light', 'Dark', 'Auto', '°C Celsius', 'km Metric', '24h (14:30)', 'On', 'Off']) {
expect(isActive(screen.getByRole('button', { name }))).toBe(false);
}
- for (const label of ['Color Mode', 'Temperature Unit', 'Distance Unit', 'Time Format', 'Display currency', 'Map Template']) {
+ for (const label of [
+ 'Color Mode',
+ 'Temperature Unit',
+ 'Distance Unit',
+ 'Time Format',
+ 'Display currency',
+ 'Map Template',
+ ]) {
expect(hasResetLink(label)).toBe(false);
}
expect(screen.getByTestId('map-preview')).toBeInTheDocument();
@@ -153,11 +165,9 @@ describe('DefaultUserSettingsTab', () => {
await waitFor(() => expect(resetLink('Distance Unit')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: '12h (2:30 PM)' }));
- await waitFor(() => expect(puts).toEqual([
- { temperature_unit: 'fahrenheit' },
- { distance_unit: 'imperial' },
- { time_format: '12h' },
- ]));
+ await waitFor(() =>
+ expect(puts).toEqual([{ temperature_unit: 'fahrenheit' }, { distance_unit: 'imperial' }, { time_format: '12h' }])
+ );
});
it('FE-ADMIN-DUS-007: a set default gets a reset link that clears it server-side', async () => {
@@ -200,10 +210,7 @@ describe('DefaultUserSettingsTab', () => {
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'On' }))).toBe(true));
await user.click(screen.getByRole('button', { name: 'Off' }));
- await waitFor(() => expect(puts).toEqual([
- { blur_booking_codes: true },
- { blur_booking_codes: false },
- ]));
+ await waitFor(() => expect(puts).toEqual([{ blur_booking_codes: true }, { blur_booking_codes: false }]));
});
it('FE-ADMIN-DUS-010: the tile preset dropdown fills the URL field and hands it to the preview', async () => {
@@ -399,7 +406,7 @@ describe('DefaultUserSettingsTab', () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json({})),
- http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
+ http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
);
withToast();
await screen.findByText('Default User Settings');
@@ -414,7 +421,7 @@ describe('DefaultUserSettingsTab', () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/default-user-settings', () => HttpResponse.json({ time_format: '12h' })),
- http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 })),
+ http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 }))
);
withToast();
await screen.findByText('Default User Settings');
diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx
index 84a2d200c..559f1d165 100644
--- a/client/src/components/Admin/DefaultUserSettingsTab.tsx
+++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx
@@ -1,18 +1,13 @@
-import React, { useEffect, useMemo, useState } from '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'
-import type { DistanceUnit, Place, WeekStart } from '../../types'
-import { weekStartOptions } from '../../utils/calendarWeek'
-import { normalizeTileUrl, withTileApiKey } from '../../utils/tileUrl'
+import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react';
+import React, { useEffect, useMemo, useState } from 'react';
+import { adminApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { useAuthStore } from '../../store/authStore';
+import type { DistanceUnit, Place, WeekStart } from '../../types';
+import { weekStartOptions } from '../../utils/calendarWeek';
+import { normalizeTileUrl, withTileApiKey } from '../../utils/tileUrl';
+import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants';
+import { MapView } from '../Map/MapView';
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
@@ -21,9 +16,14 @@ import {
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
-} from '../Map/glProviders'
-import { useAuthStore } from '../../store/authStore'
-import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
+} from '../Map/glProviders';
+import Section from '../Settings/Section';
+import { SettingRows, SettingsHint } from '../Settings/settingsKit';
+import CustomSelect from '../shared/CustomSelect';
+import { fs } from '../shared/DialogShell';
+import { useToast } from '../shared/Toast';
+import { EditorField, INPUT, Segmented } from '../shared/dialogParts';
+import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields';
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -37,36 +37,36 @@ const MAP_PRESETS = [
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
-]
+];
type Defaults = RoutingDefaults & {
- temperature_unit?: string
- distance_unit?: DistanceUnit
- dark_mode?: string | boolean
- time_format?: string
- week_start?: WeekStart
- default_currency?: string
- blur_booking_codes?: boolean
- map_tile_url?: string
- carto_api_key?: string
- map_provider?: string
- mapbox_access_token?: string
- mapbox_style?: string
- maplibre_style?: string
- mapbox_3d_enabled?: boolean
- mapbox_quality_mode?: boolean
-}
+ temperature_unit?: string;
+ distance_unit?: DistanceUnit;
+ dark_mode?: string | boolean;
+ time_format?: string;
+ week_start?: WeekStart;
+ default_currency?: string;
+ blur_booking_codes?: boolean;
+ map_tile_url?: string;
+ carto_api_key?: string;
+ map_provider?: string;
+ mapbox_access_token?: string;
+ mapbox_style?: string;
+ maplibre_style?: string;
+ mapbox_3d_enabled?: boolean;
+ mapbox_quality_mode?: boolean;
+};
-type MapProvider = 'leaflet' | GlMapProvider
+type MapProvider = 'leaflet' | GlMapProvider;
function normalizeProvider(value: unknown): MapProvider {
- return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
+ return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet';
}
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
- if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
- return normalizeStyleForProvider(provider, style)
+ if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE;
+ return normalizeStyleForProvider(provider, style);
}
/**
@@ -81,159 +81,184 @@ function ChoiceRow({
stacked = false,
children,
}: {
- label: React.ReactNode
- hint?: string
- stacked?: boolean
- children: React.ReactNode
+ label: React.ReactNode;
+ hint?: string;
+ stacked?: boolean;
+ children: React.ReactNode;
}) {
return (
-
-
+
+
{label}
- {hint &&
{hint}
}
+ {hint && (
+
+ {hint}
+
+ )}
{children}
- )
+ );
}
/** A wide field box inside the white rows, for the map card's token and style. */
-const FIELD_ROW = 'px-3.5 py-3'
+const FIELD_ROW = 'px-3.5 py-3';
/** On/Off for a boolean default; neither is pressed while it is unset. */
function onOffValue(value: boolean | undefined): string {
- if (value === true) return 'on'
- if (value === false) return 'off'
- return ''
+ if (value === true) return 'on';
+ if (value === false) return 'off';
+ return '';
}
/** The stored colour mode as one of the three choices; a legacy boolean still picks light or dark. */
function colorModeValue(value: string | boolean | undefined): string {
- if (value === true) return 'dark'
- if (value === false) return 'light'
- return value ?? ''
+ if (value === true) return 'dark';
+ if (value === false) return 'light';
+ return value ?? '';
}
export default function DefaultUserSettingsTab(): React.ReactElement {
- const { t, locale } = useTranslation()
- const toast = useToast()
- const [defaults, setDefaults] = useState({})
- const [loaded, setLoaded] = useState(false)
- const [mapTileUrl, setMapTileUrl] = useState('')
- const managed = useAuthStore((s) => s.managed)
- const [mapboxToken, setMapboxToken] = useState('')
- const [cartoKey, setCartoKey] = useState('')
- const [mapboxStyle, setMapboxStyle] = useState('')
+ const { t, locale } = useTranslation();
+ const toast = useToast();
+ const [defaults, setDefaults] = useState({});
+ const [loaded, setLoaded] = useState(false);
+ const [mapTileUrl, setMapTileUrl] = useState('');
+ const managed = useAuthStore((s) => s.managed);
+ const [mapboxToken, setMapboxToken] = useState('');
+ const [cartoKey, setCartoKey] = useState('');
+ const [mapboxStyle, setMapboxStyle] = useState('');
useEffect(() => {
- adminApi.getDefaultUserSettings().then((data: Defaults) => {
- const provider = normalizeProvider(data.map_provider)
- setDefaults(data)
- setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
- setMapboxToken(data.mapbox_access_token || '')
- setCartoKey(data.carto_api_key || '')
- setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
- setLoaded(true)
- }).catch(() => setLoaded(true))
- }, [])
+ adminApi
+ .getDefaultUserSettings()
+ .then((data: Defaults) => {
+ const provider = normalizeProvider(data.map_provider);
+ setDefaults(data);
+ setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''));
+ setMapboxToken(data.mapbox_access_token || '');
+ setCartoKey(data.carto_api_key || '');
+ setMapboxStyle(
+ provider === 'leaflet'
+ ? data.mapbox_style || ''
+ : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style)
+ );
+ setLoaded(true);
+ })
+ .catch(() => setLoaded(true));
+ }, []);
const save = async (patch: Partial) => {
try {
- const updated = await adminApi.updateDefaultUserSettings(patch as Record)
- setDefaults(updated)
- toast.success(t('admin.defaultSettings.saved'))
+ const updated = await adminApi.updateDefaultUserSettings(patch as Record);
+ setDefaults(updated);
+ toast.success(t('admin.defaultSettings.saved'));
} catch (err: unknown) {
- toast.error(err instanceof Error ? err.message : t('common.error'))
+ toast.error(err instanceof Error ? err.message : t('common.error'));
}
- }
+ };
const reset = async (key: keyof Defaults) => {
try {
- const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
- setDefaults(updated)
- if (key === 'map_tile_url') setMapTileUrl('')
- if (key === 'mapbox_access_token') setMapboxToken('')
- if (key === 'carto_api_key') setCartoKey('')
+ const updated = await adminApi.updateDefaultUserSettings({ [key]: null });
+ setDefaults(updated);
+ if (key === 'map_tile_url') setMapTileUrl('');
+ if (key === 'mapbox_access_token') setMapboxToken('');
+ if (key === 'carto_api_key') setCartoKey('');
if (key === 'mapbox_style' || key === 'maplibre_style') {
- const provider = normalizeProvider(defaults.map_provider)
- setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
+ const provider = normalizeProvider(defaults.map_provider);
+ setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider));
}
- toast.success(t('admin.defaultSettings.reset'))
+ toast.success(t('admin.defaultSettings.reset'));
} catch (err: unknown) {
- toast.error(err instanceof Error ? err.message : t('common.error'))
+ toast.error(err instanceof Error ? err.message : t('common.error'));
}
- }
+ };
- const isSet = (key: keyof Defaults) => defaults[key] !== undefined
+ const isSet = (key: keyof Defaults) => defaults[key] !== undefined;
// Inside the row's own , after the name: the link only shows once the
// default is set, and the name stays the label's text.
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
- reset(field)}
className="ml-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
- ) : null
+ ) : null;
- const mapPreviewPlaces = useMemo((): Place[] => [{
- id: 1,
- trip_id: 1,
- name: 'Preview center',
- description: null,
- notes: null,
- lat: 48.8566,
- lng: 2.3522,
- address: null,
- category_id: null,
- price: null,
- currency: null,
- image_url: null,
- google_place_id: null,
- osm_id: null,
- route_geometry: null,
- place_time: null,
- end_time: null,
- duration_minutes: null,
- transport_mode: null,
- website: null,
- phone: null,
- created_at: String(new Date()),
- }], [])
+ const mapPreviewPlaces = useMemo(
+ (): Place[] => [
+ {
+ id: 1,
+ trip_id: 1,
+ name: 'Preview center',
+ description: null,
+ notes: null,
+ lat: 48.8566,
+ lng: 2.3522,
+ address: null,
+ category_id: null,
+ price: null,
+ currency: null,
+ image_url: null,
+ google_place_id: null,
+ osm_id: null,
+ route_geometry: null,
+ place_time: null,
+ end_time: null,
+ duration_minutes: null,
+ transport_mode: null,
+ website: null,
+ phone: null,
+ created_at: String(new Date()),
+ },
+ ],
+ []
+ );
if (!loaded) {
return (
-
+
Loading…
- )
+ );
}
- const darkMode = defaults.dark_mode
- const mapProvider = normalizeProvider(defaults.map_provider)
- const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
- const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
+ const darkMode = defaults.dark_mode;
+ const mapProvider = normalizeProvider(defaults.map_provider);
+ const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider);
+ const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style';
const saveMapProvider = (nextProvider: MapProvider) => {
- const patch: Partial
= { map_provider: nextProvider }
+ const patch: Partial = { map_provider: nextProvider };
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
- const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
- const nextStyle = styleForProvider(nextProvider, slot)
- setMapboxStyle(nextStyle)
- patch[styleSettingKey(nextProvider)] = nextStyle
+ const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style;
+ const nextStyle = styleForProvider(nextProvider, slot);
+ setMapboxStyle(nextStyle);
+ patch[styleSettingKey(nextProvider)] = nextStyle;
}
- void save(patch)
- }
+ void save(patch);
+ };
return (
-
- {t('admin.defaultSettings.description')}
-
+
{t('admin.defaultSettings.description')}
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
the map fields sat a screen below the units while the right half of the page
@@ -246,256 +271,364 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
collapses. Two cards can sit straight in the grid; a third would need the
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
-
-
- {/* Color Mode */}
- {t('settings.colorMode')} >}>
-
- label={t('settings.colorMode')}
- value={colorModeValue(darkMode)}
- onChange={value => save({ dark_mode: value })}
- options={[
- { value: 'light', label: t('settings.light') },
- { value: 'dark', label: t('settings.dark') },
- { value: 'auto', label: t('settings.auto') },
- ]}
- />
-
+
+
+ {/* Color Mode */}
+
+ {t('settings.colorMode')}
+ >
+ }
+ >
+
+ label={t('settings.colorMode')}
+ value={colorModeValue(darkMode)}
+ onChange={(value) => save({ dark_mode: value })}
+ options={[
+ { value: 'light', label: t('settings.light') },
+ { value: 'dark', label: t('settings.dark') },
+ { value: 'auto', label: t('settings.auto') },
+ ]}
+ />
+
- {/* Temperature */}
- {t('settings.temperature')} >}>
-
- label={t('settings.temperature')}
- value={defaults.temperature_unit ?? ''}
- onChange={value => save({ temperature_unit: value })}
- options={[
- { value: 'celsius', label: '°C Celsius' },
- { value: 'fahrenheit', label: '°F Fahrenheit' },
- ]}
- />
-
+ {/* Temperature */}
+
+ {t('settings.temperature')}
+ >
+ }
+ >
+
+ label={t('settings.temperature')}
+ value={defaults.temperature_unit ?? ''}
+ onChange={(value) => save({ temperature_unit: value })}
+ options={[
+ { value: 'celsius', label: '°C Celsius' },
+ { value: 'fahrenheit', label: '°F Fahrenheit' },
+ ]}
+ />
+
- {/* Distance */}
- {t('settings.distance')} >}>
-
- label={t('settings.distance')}
- value={defaults.distance_unit ?? ''}
- onChange={value => { if (value) void save({ distance_unit: value }) }}
- options={[
- { value: 'metric', label: 'km Metric' },
- { value: 'imperial', label: 'mi Imperial' },
- ]}
- />
-
+ {/* Distance */}
+
+ {t('settings.distance')}
+ >
+ }
+ >
+
+ label={t('settings.distance')}
+ value={defaults.distance_unit ?? ''}
+ onChange={(value) => {
+ if (value) void save({ distance_unit: value });
+ }}
+ options={[
+ { value: 'metric', label: 'km Metric' },
+ { value: 'imperial', label: 'mi Imperial' },
+ ]}
+ />
+
- {/* Time Format */}
- {t('settings.timeFormat')} >}>
-
- label={t('settings.timeFormat')}
- value={defaults.time_format ?? ''}
- onChange={value => save({ time_format: value })}
- options={[
- { value: '24h', label: '24h (14:30)' },
- { value: '12h', label: '12h (2:30 PM)' },
- ]}
- />
-
+ {/* Time Format */}
+
+ {t('settings.timeFormat')}
+ >
+ }
+ >
+
+ label={t('settings.timeFormat')}
+ value={defaults.time_format ?? ''}
+ onChange={(value) => save({ time_format: value })}
+ options={[
+ { value: '24h', label: '24h (14:30)' },
+ { value: '12h', label: '12h (2:30 PM)' },
+ ]}
+ />
+
- {/* Week start: the first column of every date picker (#2029) */}
- {t('settings.weekStart')} >}>
-
- label={t('settings.weekStart')}
- value={defaults.week_start ?? ''}
- onChange={value => { if (value) void save({ week_start: value }) }}
- options={weekStartOptions(locale)}
- />
-
+ {/* Week start: the first column of every date picker (#2029) */}
+
+ {t('settings.weekStart')}
+ >
+ }
+ >
+
+ label={t('settings.weekStart')}
+ value={defaults.week_start ?? ''}
+ onChange={(value) => {
+ if (value) void save({ week_start: value });
+ }}
+ options={weekStartOptions(locale)}
+ />
+
- {/* Default Currency */}
- {t('settings.currency')} >}>
-
- { if (value) void save({ default_currency: value }) }}
- placeholder={t('settings.currency')}
- searchable
- options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
- />
-
-
+ {/* Default Currency */}
+
+ {t('settings.currency')}
+ >
+ }
+ >
+
+ {
+ if (value) void save({ default_currency: value });
+ }}
+ placeholder={t('settings.currency')}
+ searchable
+ options={currenciesWith(defaults.default_currency).map((c) => ({
+ value: c,
+ label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c,
+ }))}
+ />
+
+
- {/* Blur Booking Codes */}
- {t('settings.blurBookingCodes')} >}>
-
- label={t('settings.blurBookingCodes')}
- value={onOffValue(defaults.blur_booking_codes)}
- onChange={value => save({ blur_booking_codes: value === 'on' })}
- options={[
- { value: 'on', label: t('settings.on') || 'On' },
- { value: 'off', label: t('settings.off') || 'Off' },
- ]}
- />
-
-
- {t('settings.currencyHint')}
-
+ {/* Blur Booking Codes */}
+
+ {t('settings.blurBookingCodes')}
+ >
+ }
+ >
+
+ label={t('settings.blurBookingCodes')}
+ value={onOffValue(defaults.blur_booking_codes)}
+ onChange={(value) => save({ blur_booking_codes: value === 'on' })}
+ options={[
+ { value: 'on', label: t('settings.on') || 'On' },
+ { value: 'off', label: t('settings.off') || 'Off' },
+ ]}
+ />
+
+
+ {t('settings.currencyHint')}
+
-
- {/* Map Tile URL */}
- {t('settings.mapTemplate')} >} hint={t('settings.mapDefaultHint')}>
-
- { if (value) { setMapTileUrl(value); void save({ map_tile_url: value }) } }}
- placeholder={t('settings.mapTemplatePlaceholder.select')}
- options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
- />
- ) => setMapTileUrl(e.target.value)}
- onBlur={() => save({ map_tile_url: mapTileUrl })}
- placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
- className={`${INPUT} font-geist`}
- />
-
-
- {/* The key comes with the instance on a managed install, injected when the
- settings are read. A field here would only let somebody save a worse one. */}
- {!managed && (
- {t('admin.defaultSettings.cartoKey')} >} hint={t('admin.defaultSettings.cartoKeyHint')}>
- ) => setCartoKey(e.target.value)}
- onBlur={() => save({ carto_api_key: cartoKey })}
- spellCheck={false}
- autoComplete="off"
- className={`${INPUT} font-geist`}
- />
-
- )}
- {!managed && }
-
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {React.createElement(MapView as any, {
- places: mapPreviewPlaces,
- dayPlaces: [],
- route: null,
- routeSegments: null,
- selectedPlaceId: null,
- onMarkerClick: null,
- onMapClick: null,
- onMapContextMenu: null,
- center: [48.8566, 2.3522],
- zoom: 10,
- // Same as the user-facing map tab: the field holds what is being
- // edited, so the key goes back on before the preview resolves it.
- tileUrl: withTileApiKey(mapTileUrl, cartoKey),
- fitKey: null,
- dayOrderMap: [],
- leftWidth: 0,
- rightWidth: 0,
- hasInspector: false,
- })}
-
-
- {/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
-
- {t('admin.defaultSettings.mapProvider')} >}
- hint={t('admin.defaultSettings.mapProviderHint')}
- >
-
- fill
- label={t('admin.defaultSettings.mapProvider')}
- value={mapProvider}
- onChange={saveMapProvider}
- options={[
- { value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
- { value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
- { value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
- ]}
- />
-
-
- {/* The token comes with the instance on a managed install, injected when the
- settings are read. A field here would only let somebody save a worse one. */}
- {mapProvider === 'mapbox-gl' && !managed && (
-
-
{t('admin.defaultSettings.mapboxToken')} >} hint={t('admin.defaultSettings.mapboxTokenHint')}>
+
+ {/* Map Tile URL */}
+
+ {t('settings.mapTemplate')}
+
+ >
+ }
+ hint={t('settings.mapDefaultHint')}
+ >
+
+ {
+ if (value) {
+ setMapTileUrl(value);
+ void save({ map_tile_url: value });
+ }
+ }}
+ placeholder={t('settings.mapTemplatePlaceholder.select')}
+ options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
+ />
) => setMapboxToken(e.target.value)}
- onBlur={() => save({ mapbox_access_token: mapboxToken })}
- placeholder="pk.eyJ…"
+ value={mapTileUrl}
+ onChange={(e: React.ChangeEvent) => setMapTileUrl(e.target.value)}
+ onBlur={() => save({ map_tile_url: mapTileUrl })}
+ placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
+ className={`${INPUT} font-geist`}
+ />
+
+
+ {/* The key comes with the instance on a managed install, injected when the
+ settings are read. A field here would only let somebody save a worse one. */}
+ {!managed && (
+
+ {t('admin.defaultSettings.cartoKey')}
+
+ >
+ }
+ hint={t('admin.defaultSettings.cartoKeyHint')}
+ >
+ ) => setCartoKey(e.target.value)}
+ onBlur={() => save({ carto_api_key: cartoKey })}
spellCheck={false}
autoComplete="off"
className={`${INPUT} font-geist`}
/>
+ )}
+ {!managed && }
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {React.createElement(MapView as any, {
+ places: mapPreviewPlaces,
+ dayPlaces: [],
+ route: null,
+ routeSegments: null,
+ selectedPlaceId: null,
+ onMarkerClick: null,
+ onMapClick: null,
+ onMapContextMenu: null,
+ center: [48.8566, 2.3522],
+ zoom: 10,
+ // Same as the user-facing map tab: the field holds what is being
+ // edited, so the key goes back on before the preview resolves it.
+ tileUrl: withTileApiKey(mapTileUrl, cartoKey),
+ fitKey: null,
+ dayOrderMap: [],
+ leftWidth: 0,
+ rightWidth: 0,
+ hasInspector: false,
+ })}
- )}
- {mapProvider !== 'leaflet' && (
-
-
{t('admin.defaultSettings.mapboxStyle')} >}>
-
- )}
+ )}
- {mapProvider === 'mapbox-gl' && (
- <>
- {t('admin.defaultSettings.mapbox3d')} >}>
-
- label={t('admin.defaultSettings.mapbox3d')}
- value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
- onChange={value => save({ mapbox_3d_enabled: value === 'on' })}
- options={[
- { value: 'on', label: t('settings.on') || 'On' },
- { value: 'off', label: t('settings.off') || 'Off' },
- ]}
- />
-
+ {mapProvider !== 'leaflet' && (
+
+
+ {t('admin.defaultSettings.mapboxStyle')}
+
+ >
+ }
+ >
+
+ {
+ if (value) {
+ setMapboxStyle(value);
+ void save({ [styleKey]: value });
+ }
+ }}
+ placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
+ options={glStylePresets.map((p) => ({ value: p.url, label: p.name }))}
+ />
+ ) => setMapboxStyle(e.target.value)}
+ onBlur={() => {
+ const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle);
+ setMapboxStyle(nextStyle);
+ void save({ [styleKey]: nextStyle });
+ }}
+ placeholder={defaultStyleForProvider(mapProvider)}
+ className={`${INPUT} font-geist`}
+ />
+
+
+
+ )}
- {t('admin.defaultSettings.mapboxQuality')} >}>
-
- label={t('admin.defaultSettings.mapboxQuality')}
- value={onOffValue(defaults.mapbox_quality_mode ?? false)}
- onChange={value => save({ mapbox_quality_mode: value === 'on' })}
- options={[
- { value: 'on', label: t('settings.on') || 'On' },
- { value: 'off', label: t('settings.off') || 'Off' },
- ]}
- />
-
- >
- )}
-
-
+ {mapProvider === 'mapbox-gl' && (
+ <>
+
+ {t('admin.defaultSettings.mapbox3d')}
+ >
+ }
+ >
+
+ label={t('admin.defaultSettings.mapbox3d')}
+ value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
+ onChange={(value) => save({ mapbox_3d_enabled: value === 'on' })}
+ options={[
+ { value: 'on', label: t('settings.on') || 'On' },
+ { value: 'off', label: t('settings.off') || 'Off' },
+ ]}
+ />
+
+
+
+ {t('admin.defaultSettings.mapboxQuality')}
+ >
+ }
+ >
+
+ label={t('admin.defaultSettings.mapboxQuality')}
+ value={onOffValue(defaults.mapbox_quality_mode ?? false)}
+ onChange={(value) => save({ mapbox_quality_mode: value === 'on' })}
+ options={[
+ { value: 'on', label: t('settings.on') || 'On' },
+ { value: 'off', label: t('settings.off') || 'Off' },
+ ]}
+ />
+
+ >
+ )}
+
+
- )
+ );
}
diff --git a/client/src/components/Admin/DevNotificationsPanel.test.tsx b/client/src/components/Admin/DevNotificationsPanel.test.tsx
index 06c6336e9..106876c47 100644
--- a/client/src/components/Admin/DevNotificationsPanel.test.tsx
+++ b/client/src/components/Admin/DevNotificationsPanel.test.tsx
@@ -1,9 +1,9 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
-import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
-import { server } from '../../../tests/helpers/msw/server';
import { buildUser } from '../../../tests/helpers/factories';
+import { server } from '../../../tests/helpers/msw/server';
+import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
@@ -22,12 +22,22 @@ afterEach(() => {
describe('DevNotificationsPanel', () => {
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
// Wait for async data to populate conditional sections
await screen.findByText('Trip-Scoped Events');
await screen.findByText('User-Scoped Events');
@@ -36,37 +46,52 @@ describe('DevNotificationsPanel', () => {
});
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const options = Array.from(tripSelect.querySelectorAll('option'));
- const labels = options.map(o => o.textContent);
+ const labels = options.map((o) => o.textContent);
expect(labels).toContain('Paris Adventure');
expect(labels).toContain('Tokyo Trip');
});
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('User-Scoped Events');
const selects = screen.getAllByRole('combobox');
// Second combobox is the user selector (first is trip selector)
const userSelect = selects[1];
const options = Array.from(userSelect.querySelectorAll('option'));
- const labels = options.map(o => o.textContent ?? '');
- expect(labels.some(l => l.includes('admin'))).toBe(true);
- expect(labels.some(l => l.includes('alice'))).toBe(true);
+ const labels = options.map((o) => o.textContent ?? '');
+ expect(labels.some((l) => l.includes('admin'))).toBe(true);
+ expect(labels.some((l) => l.includes('alice'))).toBe(true);
});
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
let capturedBody: Record | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
- capturedBody = await request.json() as Record;
+ capturedBody = (await request.json()) as Record;
return HttpResponse.json({ ok: true });
- }),
+ })
);
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => expect(capturedBody).toBeDefined());
@@ -78,13 +103,14 @@ describe('DevNotificationsPanel', () => {
});
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
- server.use(
- http.post('/api/admin/dev/test-notification', () =>
- HttpResponse.json({ ok: true }),
- ),
- );
+ server.use(http.post('/api/admin/dev/test-notification', () => HttpResponse.json({ ok: true })));
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
@@ -95,10 +121,15 @@ describe('DevNotificationsPanel', () => {
http.post('/api/admin/dev/test-notification', async () => {
await new Promise(() => {}); // never resolves — simulates in-flight
return HttpResponse.json({ ok: true });
- }),
+ })
);
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Type Testing');
// The request handler never resolves, so sending stays true after the click settles
@@ -106,18 +137,23 @@ describe('DevNotificationsPanel', () => {
await waitFor(() => {
const buttons = screen.getAllByRole('button');
- buttons.forEach(btn => expect(btn).toBeDisabled());
+ buttons.forEach((btn) => expect(btn).toBeDisabled());
});
});
it('FE-ADMIN-DEVNOTIF-008: the server error field is what the toast shows', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
- HttpResponse.json({ error: 'No channel configured' }, { status: 500 }),
- ),
+ HttpResponse.json({ error: 'No channel configured' }, { status: 500 })
+ )
);
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
expect(await screen.findByText('No channel configured')).toBeInTheDocument();
@@ -126,11 +162,16 @@ describe('DevNotificationsPanel', () => {
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
- HttpResponse.json({ message: 'Server error' }, { status: 500 }),
- ),
+ HttpResponse.json({ message: 'Server error' }, { status: 500 })
+ )
);
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
expect(await screen.findByText('Failed')).toBeInTheDocument();
@@ -140,18 +181,21 @@ describe('DevNotificationsPanel', () => {
let capturedBody: Record | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
- capturedBody = await request.json() as Record;
+ capturedBody = (await request.json()) as Record;
return HttpResponse.json({ ok: true });
- }),
+ })
);
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
- const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
- o => o.textContent === 'Tokyo Trip',
- )!;
+ const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find((o) => o.textContent === 'Tokyo Trip')!;
const tokyoId = Number(tokyoOption.value);
await user.selectOptions(tripSelect, 'Tokyo Trip');
@@ -162,10 +206,13 @@ describe('DevNotificationsPanel', () => {
});
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
- server.use(
- http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
+ server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
+ render(
+ <>
+
+
+ >
);
- render(<> >);
// Wait for user data to confirm async effects have settled
await screen.findByText('User-Scoped Events');
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
@@ -175,12 +222,17 @@ describe('DevNotificationsPanel', () => {
const bodies: Record[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
- bodies.push(await request.json() as Record);
+ bodies.push((await request.json()) as Record);
return HttpResponse.json({ ok: true });
- }),
+ })
);
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Type Testing');
await user.click(screen.getByText('Boolean → Me').closest('button')!);
@@ -204,19 +256,29 @@ describe('DevNotificationsPanel', () => {
});
expect(bodies[1]).toMatchObject({ event: 'test_navigate', scope: 'user', targetId: ADMIN_USER.id });
expect(bodies[2]).toMatchObject({ event: 'test_simple', scope: 'admin', targetId: 0 });
- expect(bodies[3]).toMatchObject({ event: 'version_available', scope: 'admin', targetId: 0, params: { version: '9.9.9-test' } });
+ expect(bodies[3]).toMatchObject({
+ event: 'version_available',
+ scope: 'admin',
+ targetId: 0,
+ params: { version: '9.9.9-test' },
+ });
});
it('FE-ADMIN-DEVNOTIF-012: every trip-scoped button carries the selected trip and the actor', async () => {
const bodies: Record[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
- bodies.push(await request.json() as Record);
+ bodies.push((await request.json()) as Record);
return HttpResponse.json({ ok: true });
- }),
+ })
);
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
@@ -227,7 +289,7 @@ describe('DevNotificationsPanel', () => {
await screen.findByText(`Sent: ${label}`);
}
- expect(bodies.map(b => b.event)).toEqual(['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']);
+ expect(bodies.map((b) => b.event)).toEqual(['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']);
for (const body of bodies) {
expect(body.scope).toBe('trip');
expect(body.targetId).toBe(tripId);
@@ -242,17 +304,22 @@ describe('DevNotificationsPanel', () => {
const bodies: Record[] = [];
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
- bodies.push(await request.json() as Record);
+ bodies.push((await request.json()) as Record);
return HttpResponse.json({ ok: true });
- }),
+ })
);
const user = userEvent.setup();
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('User-Scoped Events');
const userSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
- const aliceOption = Array.from(userSelect.querySelectorAll('option')).find(
- o => (o.textContent ?? '').includes('alice'),
+ const aliceOption = Array.from(userSelect.querySelectorAll('option')).find((o) =>
+ (o.textContent ?? '').includes('alice')
)!;
await user.selectOptions(userSelect, aliceOption.value);
const aliceId = Number(aliceOption.value);
@@ -278,7 +345,12 @@ describe('DevNotificationsPanel', () => {
it('FE-ADMIN-DEVNOTIF-014: the User-Scoped section is hidden when no users come back', async () => {
server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [] })));
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Trip-Scoped Events');
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
@@ -287,9 +359,14 @@ describe('DevNotificationsPanel', () => {
it('FE-ADMIN-DEVNOTIF-015: failing lookups leave both scoped sections out without crashing', async () => {
server.use(
http.get('/api/trips', () => HttpResponse.error()),
- http.get('/api/admin/users', () => HttpResponse.error()),
+ http.get('/api/admin/users', () => HttpResponse.error())
+ );
+ render(
+ <>
+
+
+ >
);
- render(<> >);
expect(await screen.findByText('Type Testing')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument());
@@ -298,7 +375,12 @@ describe('DevNotificationsPanel', () => {
});
it('FE-ADMIN-DEVNOTIF-016: a trigger takes its hover background from CSS, not inline styles', async () => {
- render(<> >);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Type Testing');
const btn = screen.getByText('Simple → Me').closest('button')!;
diff --git a/client/src/components/Admin/DevNotificationsPanel.tsx b/client/src/components/Admin/DevNotificationsPanel.tsx
index 7cfd5d06d..9def90a4d 100644
--- a/client/src/components/Admin/DevNotificationsPanel.tsx
+++ b/client/src/components/Admin/DevNotificationsPanel.tsx
@@ -1,92 +1,129 @@
-import React, { useState, useEffect, useId } from 'react'
-import { adminApi, tripsApi } from '../../api/client'
-import { getApiErrorMessage } from '../../utils/apiError'
-import { useAuthStore } from '../../store/authStore'
-import { useToast } from '../shared/Toast'
-import { fs } from '../shared/DialogShell'
-import { EditorField, INPUT } from '../shared/dialogParts'
-import { SettingsCard, StatusPill } from '../Settings/settingsKit'
import {
- Bell, Zap, CheckCircle, Navigation, User, Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
- Download, MapPin, ChevronDown, FlaskConical, Loader2, Map as MapIcon, ShieldCheck,
-} from 'lucide-react'
+ Bell,
+ Calendar,
+ CheckCircle,
+ ChevronDown,
+ Clock,
+ Download,
+ FlaskConical,
+ Image,
+ Loader2,
+ Map as MapIcon,
+ MapPin,
+ MessageSquare,
+ Navigation,
+ ShieldCheck,
+ Tag,
+ User,
+ UserPlus,
+ Zap,
+} from 'lucide-react';
+import React, { useEffect, useId, useState } from 'react';
+import { adminApi, tripsApi } from '../../api/client';
+import { useAuthStore } from '../../store/authStore';
+import { getApiErrorMessage } from '../../utils/apiError';
+import { SettingsCard, StatusPill } from '../Settings/settingsKit';
+import { fs } from '../shared/DialogShell';
+import { useToast } from '../shared/Toast';
+import { EditorField, INPUT } from '../shared/dialogParts';
interface Trip {
- id: number
- title: string
+ id: number;
+ title: string;
}
interface AppUser {
- id: number
- username: string
- email: string
+ id: number;
+ username: string;
+ email: string;
}
/** A native select in the editors' box look, with the chevron the CustomSelect draws. */
-const SELECT = `${INPUT} appearance-none pr-9`
+const SELECT = `${INPUT} appearance-none pr-9`;
function SelectBox({ id, children, ...rest }: React.SelectHTMLAttributes): React.ReactElement {
return (
- {children}
-
+
+ {children}
+
+
- )
+ );
}
/** The buttons of one group, two to a row from a small window up. */
function ButtonGrid({ children }: { children: React.ReactNode }): React.ReactElement {
- return {children}
+ return {children}
;
}
export default function DevNotificationsPanel(): React.ReactElement {
- const toast = useToast()
- const ids = useId()
- const user = useAuthStore(s => s.user)
- const [sending, setSending] = useState(null)
- const [trips, setTrips] = useState([])
- const [selectedTripId, setSelectedTripId] = useState(null)
- const [users, setUsers] = useState([])
- const [selectedUserId, setSelectedUserId] = useState(null)
+ const toast = useToast();
+ const ids = useId();
+ const user = useAuthStore((s) => s.user);
+ const [sending, setSending] = useState(null);
+ const [trips, setTrips] = useState([]);
+ const [selectedTripId, setSelectedTripId] = useState(null);
+ const [users, setUsers] = useState([]);
+ const [selectedUserId, setSelectedUserId] = useState(null);
useEffect(() => {
- tripsApi.list().then(data => {
- const list = (data.trips || data || []) as Trip[]
- setTrips(list)
- if (list.length > 0) setSelectedTripId(list[0].id)
- }).catch(() => {})
- adminApi.users().then(data => {
- const list = (data.users || data || []) as AppUser[]
- setUsers(list)
- if (list.length > 0) setSelectedUserId(list[0].id)
- }).catch(() => {})
- }, [])
+ tripsApi
+ .list()
+ .then((data) => {
+ const list = (data.trips || data || []) as Trip[];
+ setTrips(list);
+ if (list.length > 0) setSelectedTripId(list[0].id);
+ })
+ .catch(() => {});
+ adminApi
+ .users()
+ .then((data) => {
+ const list = (data.users || data || []) as AppUser[];
+ setUsers(list);
+ if (list.length > 0) setSelectedUserId(list[0].id);
+ })
+ .catch(() => {});
+ }, []);
const fire = async (label: string, payload: Record