diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 566d1b4e2..7181041da 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -1,43 +1,42 @@ -import React from 'react' -import { render, screen, waitFor, act } from '@testing-library/react' -import { MemoryRouter, useLocation } from 'react-router' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { http, HttpResponse } from 'msw' -import { server } from '../tests/helpers/msw/server' -import { useAuthStore } from './store/authStore' -import { useSettingsStore } from './store/settingsStore' -import { resetAllStores } from '../tests/helpers/store' -import { buildUser, buildSettings, buildTrip } from '../tests/helpers/factories' -import { offlineDb } from './db/offlineDb' -import { SETTINGS_WAIT_MS } from './utils/startDestination' -import App from './App' +import { act, render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { MemoryRouter, useLocation } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildSettings, buildTrip, buildUser } from '../tests/helpers/factories'; +import { server } from '../tests/helpers/msw/server'; +import { resetAllStores } from '../tests/helpers/store'; +import App from './App'; +import { offlineDb } from './db/offlineDb'; +import { useAuthStore } from './store/authStore'; +import { useSettingsStore } from './store/settingsStore'; +import { SETTINGS_WAIT_MS } from './utils/startDestination'; // ── Mock page components ─────────────────────────────────────────────────────── -vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
})) -vi.mock('./pages/DashboardPage', () => ({ default: () =>
Dashboard
})) -vi.mock('./pages/TripPlannerPage', () => ({ default: () =>
TripPlanner
})) -vi.mock('./pages/FilesPage', () => ({ default: () =>
Files
})) -vi.mock('./pages/AdminPage', () => ({ default: () =>
Admin
})) -vi.mock('./pages/SettingsPage', () => ({ default: () =>
Settings
})) -vi.mock('./pages/VacayPage', () => ({ default: () =>
Vacay
})) -vi.mock('./pages/AtlasPage', () => ({ default: () =>
Atlas
})) -vi.mock('./pages/SharedTripPage', () => ({ default: () =>
SharedTrip
})) -vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () =>
Notifications
})) +vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
})); +vi.mock('./pages/DashboardPage', () => ({ default: () =>
Dashboard
})); +vi.mock('./pages/TripPlannerPage', () => ({ default: () =>
TripPlanner
})); +vi.mock('./pages/FilesPage', () => ({ default: () =>
Files
})); +vi.mock('./pages/AdminPage', () => ({ default: () =>
Admin
})); +vi.mock('./pages/SettingsPage', () => ({ default: () =>
Settings
})); +vi.mock('./pages/VacayPage', () => ({ default: () =>
Vacay
})); +vi.mock('./pages/AtlasPage', () => ({ default: () =>
Atlas
})); +vi.mock('./pages/SharedTripPage', () => ({ default: () =>
SharedTrip
})); +vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () =>
Notifications
})); // Prevent WebSocket side effects from the notification listener vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn(), -})) +})); // ── Helpers ──────────────────────────────────────────────────────────────────── /** Reports the router's current location, so a test can assert where App sent it. */ function LocationProbe() { - const loc = useLocation() - return {loc.pathname + loc.search} + const loc = useLocation(); + return {loc.pathname + loc.search}; } -const currentPath = () => screen.getByTestId('loc').textContent +const currentPath = () => screen.getByTestId('loc').textContent; function renderApp(initialPath = '/') { return render( @@ -45,7 +44,7 @@ function renderApp(initialPath = '/') { - ) + ); } /** @@ -60,179 +59,179 @@ function seedAuth(overrides: Record = {}) { appRequireMfa: false, loadUser: vi.fn().mockResolvedValue(undefined), ...overrides, - }) + }); } beforeEach(() => { - resetAllStores() - vi.clearAllMocks() - document.documentElement.classList.remove('dark') -}) + resetAllStores(); + vi.clearAllMocks(); + document.documentElement.classList.remove('dark'); +}); // ── RootRedirect ─────────────────────────────────────────────────────────────── describe('RootRedirect', () => { it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - }) + seedAuth({ isAuthenticated: false }); + renderApp('/'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + }); it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) - renderApp('/') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - }) + seedAuth({ isAuthenticated: true, user: buildUser() }); + renderApp('/'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + }); it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => { - seedAuth({ isLoading: true, isAuthenticated: false }) - renderApp('/') - expect(document.querySelector('.animate-spin')).toBeInTheDocument() - expect(screen.queryByText('Login')).not.toBeInTheDocument() - }) -}) + seedAuth({ isLoading: true, isAuthenticated: false }); + renderApp('/'); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('Login')).not.toBeInTheDocument(); + }); +}); // ── RootRedirect — startup destination ──────────────────────────────────────── describe('RootRedirect — startup destination', () => { /** Serves GET /api/trips/active and reports whether it was asked at all. */ function stubActiveTrip(trip: { id: number; title: string } | null) { - const calls: string[] = [] + const calls: string[] = []; server.use( http.get('/api/trips/active', ({ request }) => { - calls.push(request.url) - return HttpResponse.json({ trip }) - }), - ) - return calls + calls.push(request.url); + return HttpResponse.json({ trip }); + }) + ); + return calls; } it('FE-COMP-APP-026: opens the active trip on the chosen tab', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) + seedAuth({ isAuthenticated: true, user: buildUser() }); useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }), - }) - stubActiveTrip({ id: 42, title: 'Japan' }) + }); + stubActiveTrip({ id: 42, title: 'Japan' }); - renderApp('/') - await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) - }) + renderApp('/'); + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()); + }); it('FE-COMP-APP-027: falls back to the dashboard when the user has no trip', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) + seedAuth({ isAuthenticated: true, user: buildUser() }); useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }), - }) - stubActiveTrip(null) + }); + stubActiveTrip(null); - renderApp('/') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - }) + renderApp('/'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + }); it('FE-COMP-APP-028: falls back to the dashboard when the lookup fails', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) + seedAuth({ isAuthenticated: true, user: buildUser() }); useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'active_trip' }), - }) - server.use(http.get('/api/trips/active', () => HttpResponse.error())) + }); + server.use(http.get('/api/trips/active', () => HttpResponse.error())); - renderApp('/') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - }) + renderApp('/'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + }); it('FE-COMP-APP-028b: opens the cached active trip when the launch is offline', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) + seedAuth({ isAuthenticated: true, user: buildUser() }); useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'active_trip' }), - }) - const today = new Date() - const iso = (d: Date) => d.toISOString().slice(0, 10) - await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) })) - const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine') - Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }) + }); + const today = new Date(); + const iso = (d: Date) => d.toISOString().slice(0, 10); + await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) })); + const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine'); + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }); // navigator and the Dexie table are shared with every other test in the // file, so a failed assertion must not leave the rest of them offline. try { - renderApp('/') - await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) + renderApp('/'); + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()); } finally { - if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine) - delete (navigator as unknown as { onLine?: boolean }).onLine - await offlineDb.trips.clear() + if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine); + delete (navigator as unknown as { onLine?: boolean }).onLine; + await offlineDb.trips.clear(); } - }) + }); // The whole point of the localStorage mirror: the default launch must not pay // for a lookup it doesn't need. it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'dashboard' }) }) - const calls = stubActiveTrip({ id: 42, title: 'Japan' }) + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'dashboard' }) }); + const calls = stubActiveTrip({ id: 42, title: 'Japan' }); - renderApp('/') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - expect(calls).toHaveLength(0) - }) + renderApp('/'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + expect(calls).toHaveLength(0); + }); it('FE-COMP-APP-030: reads the preference from localStorage before settings have loaded', async () => { - localStorage.setItem('trek_start_page', 'active_trip') - localStorage.setItem('trek_start_trip_tab', 'finanzplan') - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ isLoaded: false }) - stubActiveTrip({ id: 42, title: 'Japan' }) + localStorage.setItem('trek_start_page', 'active_trip'); + localStorage.setItem('trek_start_trip_tab', 'finanzplan'); + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ isLoaded: false }); + stubActiveTrip({ id: 42, title: 'Japan' }); - renderApp('/') - await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) - }) -}) + renderApp('/'); + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()); + }); +}); // ── ProtectedRoute — unauthenticated ────────────────────────────────────────── describe('ProtectedRoute — unauthenticated', () => { it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/dashboard') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - }) + seedAuth({ isAuthenticated: false }); + renderApp('/dashboard'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + }); // A session that ran out should return you to the page you were on. Pressing // "log out" should not: clearing isAuthenticated re-renders this route for the // page still on screen, and the ?redirect= it stamped there beat the user's // startup destination on every login after the first. it('FE-COMP-APP-034: keeps the return ticket when the session merely ended', async () => { - seedAuth({ isAuthenticated: false, loggingOut: false }) - renderApp('/trips/1/files') - await waitFor(() => expect(currentPath()).toBe('/login?redirect=%2Ftrips%2F1%2Ffiles')) - }) + seedAuth({ isAuthenticated: false, loggingOut: false }); + renderApp('/trips/1/files'); + await waitFor(() => expect(currentPath()).toBe('/login?redirect=%2Ftrips%2F1%2Ffiles')); + }); it('FE-COMP-APP-035: drops it on a deliberate sign-out, so the startup destination decides', async () => { - seedAuth({ isAuthenticated: false, loggingOut: true }) - renderApp('/dashboard') - await waitFor(() => expect(currentPath()).toBe('/login')) - }) + seedAuth({ isAuthenticated: false, loggingOut: true }); + renderApp('/dashboard'); + await waitFor(() => expect(currentPath()).toBe('/login')); + }); it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/trips/42') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - }) -}) + seedAuth({ isAuthenticated: false }); + renderApp('/trips/42'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + }); +}); // ── ProtectedRoute — loading ─────────────────────────────────────────────────── describe('ProtectedRoute — loading state', () => { it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => { - seedAuth({ isLoading: true, isAuthenticated: false }) - renderApp('/dashboard') - expect(document.querySelector('.animate-spin')).toBeInTheDocument() - expect(screen.queryByText('Dashboard')).not.toBeInTheDocument() - }) -}) + seedAuth({ isLoading: true, isAuthenticated: false }); + renderApp('/dashboard'); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('Dashboard')).not.toBeInTheDocument(); + }); +}); // ── ProtectedRoute — MFA enforcement ────────────────────────────────────────── @@ -242,32 +241,32 @@ describe('ProtectedRoute — MFA enforcement', () => { isAuthenticated: true, appRequireMfa: true, user: buildUser({ mfa_enabled: false }), - }) - renderApp('/dashboard') - await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument()) - }) + }); + renderApp('/dashboard'); + await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument()); + }); it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => { seedAuth({ isAuthenticated: true, appRequireMfa: true, user: buildUser({ mfa_enabled: false }), - }) - renderApp('/settings') - await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument()) - expect(screen.queryByText('Login')).not.toBeInTheDocument() - }) + }); + renderApp('/settings'); + await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument()); + expect(screen.queryByText('Login')).not.toBeInTheDocument(); + }); it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => { seedAuth({ isAuthenticated: true, appRequireMfa: true, user: buildUser({ mfa_enabled: true }), - }) - renderApp('/dashboard') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - }) -}) + }); + renderApp('/dashboard'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + }); +}); // ── ProtectedRoute — admin role ──────────────────────────────────────────────── @@ -276,21 +275,21 @@ describe('ProtectedRoute — admin role check', () => { seedAuth({ isAuthenticated: true, user: buildUser({ role: 'user' }), - }) - renderApp('/admin') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - expect(screen.queryByText('Admin')).not.toBeInTheDocument() - }) + }); + renderApp('/admin'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); it('FE-COMP-APP-011: /admin is accessible for admin user', async () => { seedAuth({ isAuthenticated: true, user: buildUser({ role: 'admin' }), - }) - renderApp('/admin') - await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument()) - }) -}) + }); + renderApp('/admin'); + await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument()); + }); +}); // ── Public routes ────────────────────────────────────────────────────────────── @@ -298,23 +297,23 @@ describe('Public routes', () => { // Synchronous on purpose: LoginPage is the one page still statically imported, // so this also holds the line against someone making it lazy later. it('FE-COMP-APP-012: /login is accessible without authentication', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/login') - expect(screen.getByText('Login')).toBeInTheDocument() - }) + seedAuth({ isAuthenticated: false }); + renderApp('/login'); + expect(screen.getByText('Login')).toBeInTheDocument(); + }); it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/shared/sometoken') - expect(await screen.findByText('SharedTrip')).toBeInTheDocument() - }) + seedAuth({ isAuthenticated: false }); + renderApp('/shared/sometoken'); + expect(await screen.findByText('SharedTrip')).toBeInTheDocument(); + }); it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/does-not-exist') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - }) -}) + seedAuth({ isAuthenticated: false }); + renderApp('/does-not-exist'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + }); +}); // ── PublicRoute — redirect already-authenticated visitors ───────────────────── @@ -324,204 +323,180 @@ describe('PublicRoute — already authenticated', () => { * it actually lands — which needs settings, exactly like the cases above. */ function seedAuthedOnDashboard() { - seedAuth({ isAuthenticated: true, user: buildUser() }) + seedAuth({ isAuthenticated: true, user: buildUser() }); useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'dashboard' }), - }) + }); } it('FE-COMP-APP-012a: /login sends an authenticated visitor to the start destination', async () => { - seedAuthedOnDashboard() - renderApp('/login') - await waitFor(() => expect(currentPath()).toBe('/dashboard')) - expect(screen.queryByText('Login')).not.toBeInTheDocument() - }) + seedAuthedOnDashboard(); + renderApp('/login'); + await waitFor(() => expect(currentPath()).toBe('/dashboard')); + expect(screen.queryByText('Login')).not.toBeInTheDocument(); + }); it('FE-COMP-APP-012b: /register does the same', async () => { - seedAuthedOnDashboard() - renderApp('/register') - await waitFor(() => expect(currentPath()).toBe('/dashboard')) - expect(screen.queryByText('Login')).not.toBeInTheDocument() - }) + seedAuthedOnDashboard(); + renderApp('/register'); + await waitFor(() => expect(currentPath()).toBe('/dashboard')); + expect(screen.queryByText('Login')).not.toBeInTheDocument(); + }); it('FE-COMP-APP-012c: /login with a ?redirect= target is left to useLogin (OAuth consent handoff), not bounced', async () => { // The consent page parks its URL in ?redirect= when it needs a login; that // flow must reach the form even for an authenticated visitor, so the guard // stays out of the way whenever a redirect target is present. - seedAuthedOnDashboard() - const target = '/login?redirect=' + encodeURIComponent('/oauth/consent?client_id=x') - renderApp(target) - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - expect(currentPath()).toBe(target) - }) + seedAuthedOnDashboard(); + const target = '/login?redirect=' + encodeURIComponent('/oauth/consent?client_id=x'); + renderApp(target); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + expect(currentPath()).toBe(target); + }); it('FE-COMP-APP-012d: /register?invite= reaches the form so the token can be validated', async () => { // An invite link is the one way someone joins a trip they were invited to // from an account they are already signed into; bouncing it would swallow // the token silently. - seedAuthedOnDashboard() - renderApp('/register?invite=abc123') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - expect(currentPath()).toBe('/register?invite=abc123') - }) -}) + seedAuthedOnDashboard(); + renderApp('/register?invite=abc123'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + expect(currentPath()).toBe('/register?invite=abc123'); + }); +}); // ── App — on-mount effects ───────────────────────────────────────────────────── describe('App — on-mount effects', () => { it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => { - const loadUser = vi.fn().mockResolvedValue(undefined) - useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }) - renderApp('/dashboard') - expect(loadUser).toHaveBeenCalled() - }) + const loadUser = vi.fn().mockResolvedValue(undefined); + useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }); + renderApp('/dashboard'); + expect(loadUser).toHaveBeenCalled(); + }); it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => { - const loadUser = vi.fn().mockResolvedValue(undefined) - useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }) - renderApp('/shared/token123') - expect(loadUser).not.toHaveBeenCalled() - }) + const loadUser = vi.fn().mockResolvedValue(undefined); + useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }); + renderApp('/shared/token123'); + expect(loadUser).not.toHaveBeenCalled(); + }); it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => { - let configCalled = false + let configCalled = false; server.use( http.get('/api/auth/app-config', () => { - configCalled = true - return HttpResponse.json({}) + configCalled = true; + return HttpResponse.json({}); }) - ) - seedAuth() - renderApp('/') - await waitFor(() => expect(configCalled).toBe(true)) - }) + ); + seedAuth(); + renderApp('/'); + await waitFor(() => expect(configCalled).toBe(true)); + }); it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => { - server.use( - http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true })) - ) - const setDemoMode = vi.fn() + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true }))); + const setDemoMode = vi.fn(); useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined), setDemoMode, - }) - renderApp('/') - await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true)) - }) + }); + renderApp('/'); + await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true)); + }); it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => { - server.use( - http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true })) - ) - const setManaged = vi.fn() + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true }))); + const setManaged = vi.fn(); useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined), setManaged, - }) - renderApp('/') - await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true)) + }); + renderApp('/'); + await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true)); // An older server that does not send the field must not leave the flag // stuck on from a previous install in the same browser profile. - setManaged.mockClear() - server.use( - http.get('/api/auth/app-config', () => HttpResponse.json({})) - ) - renderApp('/') - await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false)) - }) + setManaged.mockClear(); + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({}))); + renderApp('/'); + await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false)); + }); it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => { - const loadSettings = vi.fn().mockResolvedValue(undefined) - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ loadSettings }) - renderApp('/dashboard') - await waitFor(() => expect(loadSettings).toHaveBeenCalled()) - }) -}) + const loadSettings = vi.fn().mockResolvedValue(undefined); + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ loadSettings }); + renderApp('/dashboard'); + await waitFor(() => expect(loadSettings).toHaveBeenCalled()); + }); +}); // ── Dark mode effects ────────────────────────────────────────────────────────── describe('Dark mode effects', () => { it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) }) - renderApp('/dashboard') - await waitFor(() => - expect(document.documentElement.classList.contains('dark')).toBe(true) - ) - }) + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) }); + renderApp('/dashboard'); + await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(true)); + }); it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => { - document.documentElement.classList.add('dark') - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) }) - renderApp('/dashboard') - await waitFor(() => - expect(document.documentElement.classList.contains('dark')).toBe(false) - ) - }) + document.documentElement.classList.add('dark'); + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) }); + renderApp('/dashboard'); + await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false)); + }); it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => { - document.documentElement.classList.add('dark') - useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) }) - seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) }) - renderApp('/shared/tok') - await waitFor(() => - expect(document.documentElement.classList.contains('dark')).toBe(false) - ) - }) + document.documentElement.classList.add('dark'); + useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) }); + seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) }); + renderApp('/shared/tok'); + await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false)); + }); it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => { // matchMedia stub returns matches: false by default (from setup.ts) - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) }) - renderApp('/dashboard') + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) }); + renderApp('/dashboard'); // With matches: false, dark should NOT be added - await waitFor(() => - expect(document.documentElement.classList.contains('dark')).toBe(false) - ) - }) -}) + await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false)); + }); +}); // ── Version cache-busting ────────────────────────────────────────────────────── describe('Version cache-busting', () => { it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => { - server.use( - http.get('/api/auth/app-config', () => - HttpResponse.json({ version: '2.9.10' }) - ) - ) - seedAuth() - renderApp('/') - await waitFor(() => - expect(localStorage.getItem('trek_app_version')).toBe('2.9.10') - ) - }) + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' }))); + seedAuth(); + renderApp('/'); + await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')); + }); it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => { - localStorage.setItem('trek_app_version', '2.9.9') - const reload = vi.fn() + localStorage.setItem('trek_app_version', '2.9.9'); + const reload = vi.fn(); Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload }, - }) + }); - server.use( - http.get('/api/auth/app-config', () => - HttpResponse.json({ version: '2.9.10' }) - ) - ) - seedAuth() - renderApp('/') - await waitFor(() => expect(reload).toHaveBeenCalled()) - }) + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' }))); + seedAuth(); + renderApp('/'); + await waitFor(() => expect(reload).toHaveBeenCalled()); + }); it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => { // The old handler wiped every Cache Storage bucket and every registration @@ -529,11 +504,14 @@ describe('Version cache-busting', () => { // precache finished: anyone who closed the app or lost signal inside that // window could no longer start the PWA offline at all, and it also threw // away the map tiles and documents the user had downloaded on purpose. - localStorage.setItem('trek_app_version', '2.9.9') - const cachesDelete = vi.fn().mockResolvedValue(true) - const unregister = vi.fn().mockResolvedValue(true) - const update = vi.fn().mockResolvedValue(undefined) - vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete }) + localStorage.setItem('trek_app_version', '2.9.9'); + const cachesDelete = vi.fn().mockResolvedValue(true); + const unregister = vi.fn().mockResolvedValue(true); + const update = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('caches', { + keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), + delete: cachesDelete, + }); Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { @@ -541,135 +519,142 @@ describe('Version cache-busting', () => { getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), addEventListener: vi.fn(), }, - }) + }); - server.use( - http.get('/api/auth/app-config', () => - HttpResponse.json({ version: '2.9.10' }) - ) - ) - seedAuth() - renderApp('/') + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' }))); + seedAuth(); + renderApp('/'); // It asks the worker to fetch the new build; Workbox swaps the precache // atomically once that install completes. - await waitFor(() => expect(update).toHaveBeenCalled()) - expect(cachesDelete).not.toHaveBeenCalled() - expect(unregister).not.toHaveBeenCalled() - }) + await waitFor(() => expect(update).toHaveBeenCalled()); + expect(cachesDelete).not.toHaveBeenCalled(); + expect(unregister).not.toHaveBeenCalled(); + }); it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => { // The value arrives over the wire and is written to this device's storage, // so it is validated rather than trusted. An unusable one must also not be // compared against the stored marker, or every launch would see a mismatch // and try to update. - localStorage.setItem('trek_app_version', '2.9.9') - const reload = vi.fn() - Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }) + localStorage.setItem('trek_app_version', '2.9.9'); + const reload = vi.fn(); + Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }); server.use( http.get('/api/auth/app-config', () => HttpResponse.json({ version: '', managed: false }) ) - ) - seedAuth() - renderApp('/') + ); + seedAuth(); + renderApp('/'); - await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument()) - expect(localStorage.getItem('trek_app_version')).toBe('2.9.9') - expect(reload).not.toHaveBeenCalled() - }) + await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument()); + expect(localStorage.getItem('trek_app_version')).toBe('2.9.9'); + expect(reload).not.toHaveBeenCalled(); + }); it('FE-COMP-APP-027: an update path that throws is retried on the next launch, harmlessly (#2228)', async () => { // A handover that never happened must not be recorded as done, or the // device stays on the old build for good. The retry this buys has to be // harmless: the purge that once repeated on every launch is gone, and a // failure neither deletes anything nor reloads into the old worker's shell. - localStorage.setItem('trek_app_version', '2.9.9') - const reload = vi.fn() - Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }) - const cachesDelete = vi.fn().mockResolvedValue(true) - const unregister = vi.fn().mockResolvedValue(true) - vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete }) - const getRegistration = vi.fn().mockRejectedValue(new Error('no worker')) + localStorage.setItem('trek_app_version', '2.9.9'); + const reload = vi.fn(); + Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }); + const cachesDelete = vi.fn().mockResolvedValue(true); + const unregister = vi.fn().mockResolvedValue(true); + vi.stubGlobal('caches', { + keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), + delete: cachesDelete, + }); + const getRegistration = vi.fn().mockRejectedValue(new Error('no worker')); Object.defineProperty(navigator, 'serviceWorker', { configurable: true, - value: { getRegistration, getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), addEventListener: vi.fn() }, - }) + value: { + getRegistration, + getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), + addEventListener: vi.fn(), + }, + }); server.use( - http.get('/api/auth/app-config', () => - HttpResponse.json({ version: '2.9.10', places_provider: 'google' }) - ) - ) - seedAuth() - const first = renderApp('/') - await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(1)) - await act(async () => { await Promise.resolve() }) - first.unmount() - expect(localStorage.getItem('trek_app_version')).toBe('2.9.9') + http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10', places_provider: 'google' })) + ); + seedAuth(); + const first = renderApp('/'); + await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(1)); + await act(async () => { + await Promise.resolve(); + }); + first.unmount(); + expect(localStorage.getItem('trek_app_version')).toBe('2.9.9'); - renderApp('/') - await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(2)) - await act(async () => { await Promise.resolve() }) - expect(localStorage.getItem('trek_app_version')).toBe('2.9.9') - expect(reload).not.toHaveBeenCalled() - expect(cachesDelete).not.toHaveBeenCalled() - expect(unregister).not.toHaveBeenCalled() + renderApp('/'); + await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(2)); + await act(async () => { + await Promise.resolve(); + }); + expect(localStorage.getItem('trek_app_version')).toBe('2.9.9'); + expect(reload).not.toHaveBeenCalled(); + expect(cachesDelete).not.toHaveBeenCalled(); + expect(unregister).not.toHaveBeenCalled(); // Without a reload the session carries on, so the rest of the config applies. - expect(useAuthStore.getState().placesProvider).toBe('google') - }) -}) + expect(useAuthStore.getState().placesProvider).toBe('google'); + }); +}); // Regression: a device that has never mirrored the preference must not treat // "nothing mirrored" as "user wants the dashboard". describe('RootRedirect — preference not mirrored on this device', () => { it('FE-COMP-APP-031: honours the server setting when localStorage is empty', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) + seedAuth({ isAuthenticated: true, user: buildUser() }); // Cold start: settings are still in flight when RootRedirect first runs. - useSettingsStore.setState({ isLoaded: false }) - server.use(http.get('/api/trips/active', () => HttpResponse.json({ trip: { id: 42, title: 'Japan' } }))) + useSettingsStore.setState({ isLoaded: false }); + server.use(http.get('/api/trips/active', () => HttpResponse.json({ trip: { id: 42, title: 'Japan' } }))); - renderApp('/') + renderApp('/'); // ...and land a moment later, exactly as loadSettings() does. await act(async () => { useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }), - }) - }) + }); + }); - await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) - }) + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()); + }); // Waiting for someone else's effect to fetch the settings made the decision // ride on where that request landed in the queue — behind ~380 others on a // cold start, which is how the redirect silently lost the race and fell // through to the dashboard. It now asks for them itself. it('FE-COMP-APP-033: asks for the settings itself instead of waiting to be told', async () => { - const loadSettings = vi.fn().mockResolvedValue(undefined) - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ isLoaded: false, loadSettings }) + const loadSettings = vi.fn().mockResolvedValue(undefined); + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ isLoaded: false, loadSettings }); - renderApp('/') + renderApp('/'); - await waitFor(() => expect(loadSettings).toHaveBeenCalled()) - }) + await waitFor(() => expect(loadSettings).toHaveBeenCalled()); + }); // loadSettings leaves isLoaded false on a failed request so it can retry, so // the wait above needs a floor or a launch with no backend never resolves. it('FE-COMP-APP-032: gives up on the settings after the timeout and opens the dashboard', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ isLoaded: false }) + vi.useFakeTimers({ shouldAdvanceTime: true }); + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ isLoaded: false }); - renderApp('/') - expect(screen.queryByText('Dashboard')).not.toBeInTheDocument() + renderApp('/'); + expect(screen.queryByText('Dashboard')).not.toBeInTheDocument(); - await act(async () => { vi.advanceTimersByTime(SETTINGS_WAIT_MS + 100) }) + await act(async () => { + vi.advanceTimersByTime(SETTINGS_WAIT_MS + 100); + }); - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - vi.useRealTimers() - }) -}) + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + vi.useRealTimers(); + }); +}); diff --git a/client/src/App.tsx b/client/src/App.tsx index a8963c1cd..264696368 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,65 +1,72 @@ -import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react' -import { Routes, Route, Navigate, useLocation } from 'react-router' -import { useAuthStore } from './store/authStore' -import { useSettingsStore } from './store/settingsStore' -import { applyAppearance } from './theme/applyAppearance' -import { useAddonStore } from './store/addonStore' -import { usePluginStore } from './store/pluginStore' +import React, { ReactNode, Suspense, useEffect, useRef, useState } from 'react'; +import { Navigate, Route, Routes, useLocation } from 'react-router'; +import { useAddonStore } from './store/addonStore'; +import { useAuthStore } from './store/authStore'; +import { usePluginStore } from './store/pluginStore'; +import { useSettingsStore } from './store/settingsStore'; +import { applyAppearance } from './theme/applyAppearance'; // The one page that stays in the entry chunk. Anyone logged out lands here, and // every other route redirects here first — a chunk round trip in front of the login // form would slow down the single screen that has to be there immediately. -import LoginPage from './pages/LoginPage' -import { ToastContainer } from './components/shared/Toast' -import SaveToCollectionModal from './components/Collections/SaveToCollectionModal' -import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet' -import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget' -import MobileShell from './mobile/MobileShell' -import MRouteFallback from './mobile/components/MRouteFallback' -import ErrorBoundary from './components/shared/ErrorBoundary' -import { lazyWithRetry } from './utils/lazyWithRetry' -import { reconcileAppVersion } from './utils/versionHandover' -import { useIsPhone } from './mobile/useIsPhone' -import { TranslationProvider, useTranslation } from './i18n' -import { authApi, isAuthPublicPath } from './api/client' -import { tripRepo } from './repo/tripRepo' -import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination' -import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute' -import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' -import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' -import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync' -import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers' -import OfflineBanner from './components/Layout/OfflineBanner' -import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' -import HelpPanel from './components/Help/HelpPanel' +import { authApi, isAuthPublicPath } from './api/client'; +import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'; +import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'; +import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'; +import HelpPanel from './components/Help/HelpPanel'; +import OfflineBanner from './components/Layout/OfflineBanner'; +import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'; +import ErrorBoundary from './components/shared/ErrorBoundary'; +import { ToastContainer } from './components/shared/Toast'; +import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'; +import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'; +import { TranslationProvider, useTranslation } from './i18n'; +import MobileShell from './mobile/MobileShell'; +import MRouteFallback from './mobile/components/MRouteFallback'; +import { useIsPhone } from './mobile/useIsPhone'; +import LoginPage from './pages/LoginPage'; +import { tripRepo } from './repo/tripRepo'; +import { PermissionLevel, usePermissionsStore } from './store/permissionsStore'; +import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'; +import { lazyWithRetry } from './utils/lazyWithRetry'; +import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute'; +import { + DEFAULT_START_PAGE, + DEFAULT_START_TRIP_TAB, + readStartDestination, + SETTINGS_WAIT_MS, + START_DESTINATION_ROUTE, + tripStartPath, +} from './utils/startDestination'; +import { reconcileAppVersion } from './utils/versionHandover'; // Notice action registrations (side-effect imports): -import './pages/Trips/noticeActions.js' -import { managedRoutes } from './managed' +import { managedRoutes } from './managed'; +import './pages/Trips/noticeActions.js'; // Every page below loads on demand. The entry chunk used to carry all twenty of // them eagerly, so opening /dashboard also paid for the planner, the journal, the // atlas and the vacation planner. lazyWithRetry rather than lazy: a chunk that // fails once gets a second, cache-busted attempt before the route boundary reaches // for a reload. -const PluginPage = lazyWithRetry(() => import('./pages/PluginPage')) -const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage')) -const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage')) -const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage')) -const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage')) -const FilesPage = lazyWithRetry(() => import('./pages/FilesPage')) -const AdminPage = lazyWithRetry(() => import('./pages/AdminPage')) -const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage')) -const VacayPage = lazyWithRetry(() => import('./pages/VacayPage')) -const HelpPage = lazyWithRetry(() => import('./pages/HelpPage')) -const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage')) -const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage')) -const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage')) -const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage')) -const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage')) -const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage')) -const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage')) -const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage')) -const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx')) -const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage')) +const PluginPage = lazyWithRetry(() => import('./pages/PluginPage')); +const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage')); +const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage')); +const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage')); +const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage')); +const FilesPage = lazyWithRetry(() => import('./pages/FilesPage')); +const AdminPage = lazyWithRetry(() => import('./pages/AdminPage')); +const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage')); +const VacayPage = lazyWithRetry(() => import('./pages/VacayPage')); +const HelpPage = lazyWithRetry(() => import('./pages/HelpPage')); +const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage')); +const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage')); +const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage')); +const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage')); +const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage')); +const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage')); +const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage')); +const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage')); +const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx')); +const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage')); // The ten phone screens are chunks of their own, alongside the desktop pages // rather than inside them. Each page used to import its M screen statically and @@ -67,69 +74,64 @@ const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePag // viewport only picked which half stayed dark. Here the branch decides the // chunk: a phone never loads the desktop planner, a desktop never the mobile // shell. -const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard')) -const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell')) -const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin')) -const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings')) -const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay')) -const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas')) -const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney')) -const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail')) -const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections')) -const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications')) +const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard')); +const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell')); +const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin')); +const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings')); +const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay')); +const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas')); +const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney')); +const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail')); +const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections')); +const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications')); interface ProtectedRouteProps { - children: ReactNode - adminRequired?: boolean - addonId?: string + children: ReactNode; + adminRequired?: boolean; + addonId?: string; } function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) { - const isAuthenticated = useAuthStore((s) => s.isAuthenticated) - const user = useAuthStore((s) => s.user) - const isLoading = useAuthStore((s) => s.isLoading) - const appRequireMfa = useAuthStore((s) => s.appRequireMfa) - const loggingOut = useAuthStore((s) => s.loggingOut) - const addonStore = useAddonStore() - const { t } = useTranslation() - const location = useLocation() - const isPhone = useIsPhone() + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const user = useAuthStore((s) => s.user); + const isLoading = useAuthStore((s) => s.isLoading); + const appRequireMfa = useAuthStore((s) => s.appRequireMfa); + const loggingOut = useAuthStore((s) => s.loggingOut); + const addonStore = useAddonStore(); + const { t } = useTranslation(); + const location = useLocation(); + const isPhone = useIsPhone(); if (isLoading) { return ( -
+
-
-

{t('common.loading')}

+
+

{t('common.loading')}

- ) + ); } if (!isAuthenticated) { // A session that ended on its own should come back to where it left off; a // deliberate sign-out is a fresh start and gets no return ticket, so the // startup destination decides where the next login lands. - if (loggingOut) return - const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash) - return + if (loggingOut) return ; + const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash); + return ; } - if ( - appRequireMfa && - user && - !user.mfa_enabled && - location.pathname !== '/settings' - ) { - return + if (appRequireMfa && user && !user.mfa_enabled && location.pathname !== '/settings') { + return ; } if (adminRequired && user && user.role !== 'admin') { - return + return ; } if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) { - return + return ; } // Below the md breakpoint the new mobile shell owns chrome (tokens, dock, @@ -146,16 +148,11 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR return ( - + {children} - ) + ); } /** @@ -164,7 +161,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR * failed, and the two anonymous share pages. */ function PublicRoute({ children, redirectAuthed = false }: { children: React.ReactNode; redirectAuthed?: boolean }) { - const location = useLocation() + const location = useLocation(); // redirectAuthed (only /login and /register) bounces a visitor who is already // authenticated when they land here — manual URL, browser back button (#1810). // Only on a bare URL, because every parameter this page takes is a flow that @@ -178,15 +175,15 @@ function PublicRoute({ children, redirectAuthed = false }: { children: React.Rea // useLogin navigates away. // The target is START_DESTINATION_ROUTE, not /dashboard, so the bounce honours // the start-page preference the same way useLogin does. - const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated) + const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated); if (redirectAuthed && wasAuthenticated.current && !location.search) { - return + return ; } return ( {children} - ) + ); } /** @@ -195,12 +192,15 @@ function PublicRoute({ children, redirectAuthed = false }: { children: React.Rea * point: the ternary that used to sit in each page only ran once the browser * had already paid for both trees. */ -function ViewportRoute({ phone: Phone, desktop: Desktop }: { - phone: React.ComponentType - desktop: React.ComponentType +function ViewportRoute({ + phone: Phone, + desktop: Desktop, +}: { + phone: React.ComponentType; + desktop: React.ComponentType; }): React.ReactElement { - const isPhone = useIsPhone() - return isPhone ? : + const isPhone = useIsPhone(); + return isPhone ? : ; } /** @@ -218,65 +218,78 @@ function ViewportRoute({ phone: Phone, desktop: Desktop }: { */ /** Remembers the protected route for the installed app's relaunch (#1024). Renders nothing. */ function RouteMemory() { - useRememberRoute() - return null + useRememberRoute(); + return null; } function RootRedirect() { - const { isAuthenticated, isLoading } = useAuthStore() - const settingsLoaded = useSettingsStore((s) => s.isLoaded) - const settings = useSettingsStore((s) => s.settings) - const loadSettings = useSettingsStore((s) => s.loadSettings) - const [target, setTarget] = useState(null) + const { isAuthenticated, isLoading } = useAuthStore(); + const settingsLoaded = useSettingsStore((s) => s.isLoaded); + const settings = useSettingsStore((s) => s.settings); + const loadSettings = useSettingsStore((s) => s.loadSettings); + const [target, setTarget] = useState(null); // Bounds the wait below: loadSettings deliberately leaves isLoaded false on a // failed request so it can retry, which would otherwise strand a launch that // started offline on the spinner. - const [settingsGaveUp, setSettingsGaveUp] = useState(false) + const [settingsGaveUp, setSettingsGaveUp] = useState(false); useEffect(() => { - if (settingsLoaded) return - const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS) - return () => clearTimeout(timer) - }, [settingsLoaded]) + if (settingsLoaded) return; + const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS); + return () => clearTimeout(timer); + }, [settingsLoaded]); useEffect(() => { - if (isLoading || !isAuthenticated || target) return + if (isLoading || !isAuthenticated || target) return; // The installed app coming back after the system threw it away (#1024): // back to the trip and tab it was in, ahead of the start page. - const resume = takeResumeRoute() - if (resume) { setTarget(resume); return } - const mirrored = readStartDestination() + const resume = takeResumeRoute(); + if (resume) { + setTarget(resume); + return; + } + const mirrored = readStartDestination(); if (!mirrored && !settingsLoaded && !settingsGaveUp) { // Ask for the settings instead of waiting for whoever else might. The // store de-dupes concurrent loads, so this is free when one is already in // flight — and it stops the decision from riding on where that request // happens to land in the queue behind everything else a launch fires off. - loadSettings() - return + loadSettings(); + return; } // Loaded settings are the truth; the mirror is what we knew last time. const { page, tab } = settingsLoaded ? { page: settings.start_page ?? DEFAULT_START_PAGE, tab: settings.start_trip_tab ?? DEFAULT_START_TRIP_TAB } - : (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB }) - if (page !== 'active_trip') { setTarget('/dashboard'); return } - let cancelled = false + : (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB }); + if (page !== 'active_trip') { + setTarget('/dashboard'); + return; + } + let cancelled = false; // Through the repo, not the api: offline this answers from Dexie instead of // bouncing a cached trip to the dashboard. - tripRepo.active() - .then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') }) - .catch(() => { if (!cancelled) setTarget('/dashboard') }) - return () => { cancelled = true } - }, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target]) + tripRepo + .active() + .then(({ trip }) => { + if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard'); + }) + .catch(() => { + if (!cancelled) setTarget('/dashboard'); + }); + return () => { + cancelled = true; + }; + }, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target]); if (isLoading || (isAuthenticated && !target)) { return ( -
-
+
+
- ) + ); } - return + return ; } /** @@ -288,77 +301,127 @@ function RouteFallback() { // The fallback renders above MobileShell, so it has to pick its own palette — // on a phone the desktop spinner on bg-surface would be a foreign white sheet // in front of the mobile screen. - const isPhone = useIsPhone() - if (isPhone) return + const isPhone = useIsPhone(); + if (isPhone) return ; return ( -
-
+
+
- ) + ); } export default function App() { - const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore() - const { loadSettings } = useSettingsStore() - const { loadAddons } = useAddonStore() - const { loadPlugins } = usePluginStore() + const { + loadUser, + isAuthenticated, + demoMode, + setManaged, + setDemoMode, + setDevMode, + setIsPrerelease, + setAppVersion, + setHasMapsKey, + setHasAmapKey, + setPlacesProvider, + setServerTimezone, + setAppRequireMfa, + setTripRemindersEnabled, + setPlacesPhotosEnabled, + setPlacesAutocompleteEnabled, + setPlacesDetailsEnabled, + setPlacesEnrichEnabled, + setPlaceShadowEnabled, + } = useAuthStore(); + const { loadSettings } = useSettingsStore(); + const { loadAddons } = useAddonStore(); + const { loadPlugins } = usePluginStore(); useEffect(() => { - if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) { + if ( + !location.pathname.startsWith('/shared/') && + !location.pathname.startsWith('/public/') && + !location.pathname.startsWith('/login') + ) { // If the persist snapshot already has an authenticated user, validate // silently so the PWA shell renders immediately without a spinner. - const alreadyAuthenticated = useAuthStore.getState().isAuthenticated + const alreadyAuthenticated = useAuthStore.getState().isAuthenticated; if (alreadyAuthenticated) { - useAuthStore.setState({ isLoading: false }) - loadUser({ silent: true }) + useAuthStore.setState({ isLoading: false }); + loadUser({ silent: true }); } else { - loadUser() + loadUser(); } } - authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record }) => { - setManaged(!!config?.managed) - setDemoMode(!!config?.demo_mode) - if (config?.dev_mode) setDevMode(true) - if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) - if (config?.version) setAppVersion(config.version) - if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) - if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key) - if (config?.places_provider) setPlacesProvider(config.places_provider) - if (config?.timezone) setServerTimezone(config.timezone) - if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) - if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) - if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled) - if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled) - if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled) - if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled) - if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled) - if (typeof config?.max_upload_mb === 'number' && config.max_upload_mb > 0) useAuthStore.getState().setMaxUploadMb(config.max_upload_mb) - if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) - // Last, since a new release reloads the page from here. - await reconcileAppVersion(config?.version) - }).catch(() => {}) - }, []) + authApi + .getAppConfig() + .then( + async (config: { + managed?: boolean; + demo_mode?: boolean; + dev_mode?: boolean; + is_prerelease?: boolean; + has_maps_key?: boolean; + has_amap_key?: boolean; + places_provider?: string; + version?: string; + timezone?: string; + require_mfa?: boolean; + trip_reminders_enabled?: boolean; + places_photos_enabled?: boolean; + places_autocomplete_enabled?: boolean; + places_details_enabled?: boolean; + places_enrich_enabled?: boolean; + place_shadow_enabled?: boolean; + max_upload_mb?: number; + permissions?: Record; + }) => { + setManaged(!!config?.managed); + setDemoMode(!!config?.demo_mode); + if (config?.dev_mode) setDevMode(true); + if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease); + if (config?.version) setAppVersion(config.version); + if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key); + if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key); + if (config?.places_provider) setPlacesProvider(config.places_provider); + if (config?.timezone) setServerTimezone(config.timezone); + if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa); + if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled); + if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled); + if (config?.places_autocomplete_enabled !== undefined) + setPlacesAutocompleteEnabled(config.places_autocomplete_enabled); + if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled); + if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled); + if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled); + if (typeof config?.max_upload_mb === 'number' && config.max_upload_mb > 0) + useAuthStore.getState().setMaxUploadMb(config.max_upload_mb); + if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions); + // Last, since a new release reloads the page from here. + await reconcileAppVersion(config?.version); + } + ) + .catch(() => {}); + }, []); - const { settings } = useSettingsStore() + const { settings } = useSettingsStore(); - useInAppNotificationListener() - useRoadtripPreferencesSync() + useInAppNotificationListener(); + useRoadtripPreferencesSync(); useEffect(() => { if (isAuthenticated) { - loadSettings() - loadAddons() - loadPlugins() + loadSettings(); + loadAddons(); + loadPlugins(); } - }, [isAuthenticated]) + }, [isAuthenticated]); useEffect(() => { - registerSyncTriggers() - return () => unregisterSyncTriggers() - }, []) + registerSyncTriggers(); + return () => unregisterSyncTriggers(); + }, []); - const location = useLocation() - const isSharedPage = location.pathname.startsWith('/shared/') + const location = useLocation(); + const isSharedPage = location.pathname.startsWith('/shared/'); useEffect(() => { const run = () => @@ -366,22 +429,23 @@ export default function App() { darkMode: settings.dark_mode, appearance: settings.appearance, isSharedPage, - }) - run() + }); + run(); // Re-resolve on OS theme change while in auto mode. if (!isSharedPage && settings.dark_mode === 'auto') { - const mq = window.matchMedia('(prefers-color-scheme: dark)') - const handler = () => run() - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => run(); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); } - }, [settings.dark_mode, settings.appearance, isSharedPage]) + }, [settings.dark_mode, settings.appearance, isSharedPage]); - const isPhone = useIsPhone() - const isAuthPage = location.pathname.startsWith('/login') - || location.pathname.startsWith('/register') - || location.pathname.startsWith('/forgot-password') - || location.pathname.startsWith('/reset-password') + const isPhone = useIsPhone(); + const isAuthPage = + location.pathname.startsWith('/login') || + location.pathname.startsWith('/register') || + location.pathname.startsWith('/forgot-password') || + location.pathname.startsWith('/reset-password'); // No session on these, so authenticated-only widgets (system notices, // background tasks, save-to-collection) have nothing to do and would only fire // a doomed authenticated request on mount. @@ -390,16 +454,32 @@ export default function App() { // drifted, this one naming only /public/journey/ while the response // interceptor's covers all of /public/. A route that is public to one and not // to the other is exactly the seam that puts a 401 back. - const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname) + const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname); return ( - {!hideAuthedWidgets && } - {!hideAuthedWidgets && } - - {!hideAuthedWidgets && } + {!hideAuthedWidgets && ( + + + + )} + {!hideAuthedWidgets && ( + + + + )} + + + + {!hideAuthedWidgets && ( + + + + )} {!hideAuthedWidgets && (isPhone ? : )} - + + + {/* One boundary for all route chunks, above so it stays mounted across navigations. react-router runs location updates inside a transition, so a mounted boundary keeps the current page on screen instead of flashing @@ -407,14 +487,63 @@ export default function App() { }> } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} - } /> + + + + } + /> {/* Empty in this repository, and read unconditionally so the public build walks the same path as any other. See client/src/managed. */} - {managedRoutes.map(r => ( + {managedRoutes.map((r) => ( - ) + ); } diff --git a/client/src/App.versionHandover.test.tsx b/client/src/App.versionHandover.test.tsx index 473a24cf7..61e6ae1a2 100644 --- a/client/src/App.versionHandover.test.tsx +++ b/client/src/App.versionHandover.test.tsx @@ -1,12 +1,11 @@ -import React from 'react' -import { render, waitFor, act } from '@testing-library/react' -import { MemoryRouter } from 'react-router' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { http, HttpResponse } from 'msw' -import { server } from '../tests/helpers/msw/server' -import { useAuthStore } from './store/authStore' -import { resetAllStores } from '../tests/helpers/store' -import App from './App' +import { act, render, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { server } from '../tests/helpers/msw/server'; +import { resetAllStores } from '../tests/helpers/store'; +import App from './App'; +import { useAuthStore } from './store/authStore'; /* * #2458: an installed PWA kept serving the previous UI after a server upgrade. @@ -23,233 +22,244 @@ import App from './App' */ const sw = vi.hoisted(() => { class FakeWorker extends EventTarget { - state: string - readonly scriptURL = '/sw.js' + state: string; + readonly scriptURL = '/sw.js'; constructor(state: string) { - super() - this.state = state + super(); + this.state = state; } become(state: string) { - this.state = state - this.dispatchEvent(new Event('statechange')) + this.state = state; + this.dispatchEvent(new Event('statechange')); } } class FakeRegistration extends EventTarget { - active: FakeWorker | null = null - installing: FakeWorker | null = null - waiting: FakeWorker | null = null - readonly scope = '/' - update = vi.fn() + active: FakeWorker | null = null; + installing: FakeWorker | null = null; + waiting: FakeWorker | null = null; + readonly scope = '/'; + update = vi.fn(); } class FakeContainer extends EventTarget { - controller: FakeWorker | null = null - getRegistration = vi.fn() + controller: FakeWorker | null = null; + getRegistration = vi.fn(); // Listeners a page adds die with that page. Those added while modules load // (before `trackPage` is switched on) stand for module-level code and stay. - trackPage = false - private pageListeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]> = [] - addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) { - super.addEventListener(type, listener, options) - if (this.trackPage && listener) this.pageListeners.push([type, listener, options]) + trackPage = false; + private pageListeners: Array< + [string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined] + > = []; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions + ) { + super.addEventListener(type, listener, options); + if (this.trackPage && listener) this.pageListeners.push([type, listener, options]); } dropPageListeners() { - for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options) - this.pageListeners = [] + for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options); + this.pageListeners = []; } } - const oldWorker = new FakeWorker('activated') - const registration = new FakeRegistration() - const container = new FakeContainer() - registration.active = oldWorker - container.controller = oldWorker - container.getRegistration.mockImplementation(async () => registration) - Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) - return { FakeWorker, registration, container, oldWorker } -}) + const oldWorker = new FakeWorker('activated'); + const registration = new FakeRegistration(); + const container = new FakeContainer(); + registration.active = oldWorker; + container.controller = oldWorker; + container.getRegistration.mockImplementation(async () => registration); + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }); + return { FakeWorker, registration, container, oldWorker }; +}); -vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
})) -vi.mock('./pages/DashboardPage', () => ({ default: () =>
Dashboard
})) -vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() })) +vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
})); +vi.mock('./pages/DashboardPage', () => ({ default: () =>
Dashboard
})); +vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() })); // Every import above has been evaluated by now; from here on a listener belongs to a page. -sw.container.trackPage = true +sw.container.trackPage = true; -const reload = vi.fn() -let configHits = 0 +const reload = vi.fn(); +let configHits = 0; function serveVersion(version: string, gate?: Promise) { server.use( http.get('/api/auth/app-config', async () => { - configHits++ - if (gate) await gate - return HttpResponse.json({ version }) - }), - ) + configHits++; + if (gate) await gate; + return HttpResponse.json({ version }); + }) + ); } /** The browser's handover: the new worker installs, activates and claims the page. */ function handOver() { - const next = new sw.FakeWorker('activated') - sw.registration.installing = null - sw.registration.active = next - sw.container.controller = next - sw.container.dispatchEvent(new Event('controllerchange')) + const next = new sw.FakeWorker('activated'); + sw.registration.installing = null; + sw.registration.active = next; + sw.container.controller = next; + sw.container.dispatchEvent(new Event('controllerchange')); } /** update() finds a new sw.js and starts installing it; `then` decides how the install ends. */ function updateStartsInstall(then: 'activates' | 'fails') { sw.registration.update.mockImplementation(async () => { - const next = new sw.FakeWorker('installing') - sw.registration.installing = next + const next = new sw.FakeWorker('installing'); + sw.registration.installing = next; setTimeout(() => { if (then === 'activates') { - next.become('activated') - handOver() + next.become('activated'); + handOver(); } else { // Workbox precaching is all-or-nothing: one failed fetch out of ~463 // rejects the install event and the new worker is thrown away. - sw.registration.installing = null - next.become('redundant') + sw.registration.installing = null; + next.become('redundant'); } - }, 0) - return sw.registration - }) + }, 0); + return sw.registration; + }); } /** One cold start of the installed app: fresh page, fresh session, same localStorage. */ async function launch() { - sessionStorage.clear() - const hitsBefore = configHits + sessionStorage.clear(); + const hitsBefore = configHits; const view = render( - , - ) - await waitFor(() => expect(configHits).toBe(hitsBefore + 1)) + + ); + await waitFor(() => expect(configHits).toBe(hitsBefore + 1)); // Let the probe, update() and the fake install run to completion. - await act(async () => { await new Promise(r => setTimeout(r, 50)) }) - view.unmount() - sw.container.dropPageListeners() + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + view.unmount(); + sw.container.dropPageListeners(); } beforeEach(() => { - resetAllStores() - vi.clearAllMocks() - configHits = 0 - sw.registration.active = sw.oldWorker - sw.registration.installing = null - sw.registration.waiting = null - sw.registration.update.mockReset() - sw.registration.update.mockResolvedValue(sw.registration) - sw.container.controller = sw.oldWorker - sw.container.dropPageListeners() - sw.container.getRegistration.mockReset() - sw.container.getRegistration.mockImplementation(async () => sw.registration) - reload.mockReset() - Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }) + resetAllStores(); + vi.clearAllMocks(); + configHits = 0; + sw.registration.active = sw.oldWorker; + sw.registration.installing = null; + sw.registration.waiting = null; + sw.registration.update.mockReset(); + sw.registration.update.mockResolvedValue(sw.registration); + sw.container.controller = sw.oldWorker; + sw.container.dropPageListeners(); + sw.container.getRegistration.mockReset(); + sw.container.getRegistration.mockImplementation(async () => sw.registration); + reload.mockReset(); + Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }); server.use( http.get('/api/health/features', () => HttpResponse.json({ bookingImport: false, aiParsing: false })), - http.get('/api/system-notices/active', () => HttpResponse.json([])), - ) + http.get('/api/system-notices/active', () => HttpResponse.json([])) + ); useAuthStore.setState({ isLoading: false, isAuthenticated: false, user: null, appRequireMfa: false, loadUser: vi.fn().mockResolvedValue(undefined), - }) -}) + }); +}); describe('#2458 version handover: behaviour that must survive the fix', () => { it('FE-COMP-APP-2458-P1: a confirmed handover reloads once and is not repeated on the next launch', async () => { - localStorage.setItem('trek_app_version', '4.3.0') - serveVersion('4.3.1') - updateStartsInstall('activates') + localStorage.setItem('trek_app_version', '4.3.0'); + serveVersion('4.3.1'); + updateStartsInstall('activates'); - await launch() - expect(sw.registration.update).toHaveBeenCalledTimes(1) - expect(reload).toHaveBeenCalledTimes(1) - expect(localStorage.getItem('trek_app_version')).toBe('4.3.1') + await launch(); + expect(sw.registration.update).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('trek_app_version')).toBe('4.3.1'); - await launch() - expect(sw.registration.update).toHaveBeenCalledTimes(1) - expect(reload).toHaveBeenCalledTimes(1) - }) + await launch(); + expect(sw.registration.update).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledTimes(1); + }); it('FE-COMP-APP-2458-P2: nothing to hand over (sw.js unchanged, same worker in control) neither reloads nor retries', async () => { // A release that changed nothing the precache holds leaves sw.js byte-identical, // so update() installs nothing. The bundle this page runs is already current. - localStorage.setItem('trek_app_version', '4.3.0') - serveVersion('4.3.1') + localStorage.setItem('trek_app_version', '4.3.0'); + serveVersion('4.3.1'); - await launch() - expect(sw.registration.update).toHaveBeenCalledTimes(1) - expect(reload).not.toHaveBeenCalled() + await launch(); + expect(sw.registration.update).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); - await launch() - expect(sw.registration.update).toHaveBeenCalledTimes(1) - expect(reload).not.toHaveBeenCalled() - }) + await launch(); + expect(sw.registration.update).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); + }); it.each([ ['a self-built image without the build arg', 'dev'], ['a test instance pinned to an older tag', '4.0.0'], ['a prerelease image, whose bundle keeps the last stable version', '4.4.0-pre.3'], - ])('FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', async (_label, serverVersion) => { - // The server reports APP_VERSION, the bundle carries client/package.json. On - // these setups the two never agree, so a check against the bundle's own - // version would see an update on every launch. - serveVersion(serverVersion) + ])( + 'FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', + async (_label, serverVersion) => { + // The server reports APP_VERSION, the bundle carries client/package.json. On + // these setups the two never agree, so a check against the bundle's own + // version would see an update on every launch. + serveVersion(serverVersion); - await launch() - await launch() - await launch() - expect(reload).not.toHaveBeenCalled() - expect(sw.registration.update).not.toHaveBeenCalled() - }) -}) + await launch(); + await launch(); + await launch(); + expect(reload).not.toHaveBeenCalled(); + expect(sw.registration.update).not.toHaveBeenCalled(); + } + ); +}); describe('#2458 version handover: the reported defect', () => { it('FE-COMP-APP-2458-01: an update() that rejects is retried on the next launch', async () => { - localStorage.setItem('trek_app_version', '4.3.0') - serveVersion('4.3.1') - sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error')) + localStorage.setItem('trek_app_version', '4.3.0'); + serveVersion('4.3.1'); + sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error')); - await launch() - expect(sw.registration.update).toHaveBeenCalledTimes(1) + await launch(); + expect(sw.registration.update).toHaveBeenCalledTimes(1); // Next cold start, network fine this time. - updateStartsInstall('activates') - await launch() - expect(sw.registration.update).toHaveBeenCalledTimes(2) - expect(reload).toHaveBeenCalled() - }) + updateStartsInstall('activates'); + await launch(); + expect(sw.registration.update).toHaveBeenCalledTimes(2); + expect(reload).toHaveBeenCalled(); + }); it('FE-COMP-APP-2458-02: a failed update() does not reload into the shell the same worker serves again', async () => { // The old worker still controls the page, so a plain reload is answered from // its precache: the same old UI, now with the marker saying it is current. - localStorage.setItem('trek_app_version', '4.3.0') - serveVersion('4.3.1') - sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error')) + localStorage.setItem('trek_app_version', '4.3.0'); + serveVersion('4.3.1'); + sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error')); - await launch() - expect(reload).not.toHaveBeenCalled() - }) + await launch(); + expect(reload).not.toHaveBeenCalled(); + }); it('FE-COMP-APP-2458-03: an install that goes redundant is retried on the next launch', async () => { - localStorage.setItem('trek_app_version', '4.3.0') - serveVersion('4.3.1') - updateStartsInstall('fails') + localStorage.setItem('trek_app_version', '4.3.0'); + serveVersion('4.3.1'); + updateStartsInstall('fails'); - await launch() - expect(sw.registration.update).toHaveBeenCalledTimes(1) - expect(reload).not.toHaveBeenCalled() + await launch(); + expect(sw.registration.update).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); - updateStartsInstall('activates') - await launch() - expect(sw.registration.update).toHaveBeenCalledTimes(2) - expect(reload).toHaveBeenCalledTimes(1) - }) + updateStartsInstall('activates'); + await launch(); + expect(sw.registration.update).toHaveBeenCalledTimes(2); + expect(reload).toHaveBeenCalledTimes(1); + }); // Kept last: it swaps the controlling worker mid-launch, which code that // remembers "the controller changed since this page loaded" keeps seeing. @@ -258,24 +268,28 @@ describe('#2458 version handover: the reported defect', () => { // installs and activates the new worker before /api/auth/app-config has // answered, the takeover happens while nobody listens, and update() then // finds nothing newer. The page keeps running the bundle the old worker served. - localStorage.setItem('trek_app_version', '4.3.0') - let open!: () => void - const gate = new Promise(r => { open = r }) - serveVersion('4.3.1', gate) + localStorage.setItem('trek_app_version', '4.3.0'); + let open!: () => void; + const gate = new Promise((r) => { + open = r; + }); + serveVersion('4.3.1', gate); - sessionStorage.clear() + sessionStorage.clear(); const view = render( - , - ) - await waitFor(() => expect(configHits).toBe(1)) - act(() => handOver()) - open() - await act(async () => { await new Promise(r => setTimeout(r, 50)) }) - view.unmount() - sw.container.dropPageListeners() + + ); + await waitFor(() => expect(configHits).toBe(1)); + act(() => handOver()); + open(); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + view.unmount(); + sw.container.dropPageListeners(); - expect(reload).toHaveBeenCalledTimes(1) - }) -}) + expect(reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/App.viewport.test.tsx b/client/src/App.viewport.test.tsx index 13c37d5e5..c6b054a5d 100644 --- a/client/src/App.viewport.test.tsx +++ b/client/src/App.viewport.test.tsx @@ -1,12 +1,11 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { useAuthStore } from './store/authStore' -import { useAddonStore } from './store/addonStore' -import { resetAllStores } from '../tests/helpers/store' -import { buildUser } from '../tests/helpers/factories' -import App from './App' +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildUser } from '../tests/helpers/factories'; +import { resetAllStores } from '../tests/helpers/store'; +import App from './App'; +import { useAddonStore } from './store/addonStore'; +import { useAuthStore } from './store/authStore'; /** * The viewport decides which chunk gets fetched, so the switch lives in App.tsx @@ -17,35 +16,35 @@ import App from './App' * root hangs off the route, and the desktop page is never rendered at all. */ -const isPhone = vi.hoisted(() => ({ value: false })) -vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value })) +const isPhone = vi.hoisted(() => ({ value: false })); +vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value })); -vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () =>
m-dashboard
})) -vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () =>
m-trip
})) -vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () =>
m-admin
})) -vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () =>
m-settings
})) -vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () =>
m-vacay
})) -vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () =>
m-atlas
})) -vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () =>
m-journey
})) -vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () =>
m-journey-detail
})) -vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () =>
m-collections
})) -vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () =>
m-notifications
})) +vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () =>
m-dashboard
})); +vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () =>
m-trip
})); +vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () =>
m-admin
})); +vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () =>
m-settings
})); +vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () =>
m-vacay
})); +vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () =>
m-atlas
})); +vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () =>
m-journey
})); +vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () =>
m-journey-detail
})); +vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () =>
m-collections
})); +vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () =>
m-notifications
})); -vi.mock('./pages/DashboardPage', () => ({ default: () =>
d-dashboard
})) -vi.mock('./pages/TripPlannerPage', () => ({ default: () =>
d-trip
})) -vi.mock('./pages/AdminPage', () => ({ default: () =>
d-admin
})) -vi.mock('./pages/SettingsPage', () => ({ default: () =>
d-settings
})) -vi.mock('./pages/VacayPage', () => ({ default: () =>
d-vacay
})) -vi.mock('./pages/AtlasPage', () => ({ default: () =>
d-atlas
})) -vi.mock('./pages/JourneyPage', () => ({ default: () =>
d-journey
})) -vi.mock('./pages/JourneyDetailPage', () => ({ default: () =>
d-journey-detail
})) -vi.mock('./pages/CollectionsPage', () => ({ default: () =>
d-collections
})) -vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () =>
d-notifications
})) +vi.mock('./pages/DashboardPage', () => ({ default: () =>
d-dashboard
})); +vi.mock('./pages/TripPlannerPage', () => ({ default: () =>
d-trip
})); +vi.mock('./pages/AdminPage', () => ({ default: () =>
d-admin
})); +vi.mock('./pages/SettingsPage', () => ({ default: () =>
d-settings
})); +vi.mock('./pages/VacayPage', () => ({ default: () =>
d-vacay
})); +vi.mock('./pages/AtlasPage', () => ({ default: () =>
d-atlas
})); +vi.mock('./pages/JourneyPage', () => ({ default: () =>
d-journey
})); +vi.mock('./pages/JourneyDetailPage', () => ({ default: () =>
d-journey-detail
})); +vi.mock('./pages/CollectionsPage', () => ({ default: () =>
d-collections
})); +vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () =>
d-notifications
})); // The notification listener opens a WebSocket on mount. vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn(), -})) +})); /** path, mobile marker, desktop marker */ const ROUTES: [string, string, string][] = [ @@ -60,20 +59,20 @@ const ROUTES: [string, string, string][] = [ ['/collections', 'm-collections', 'd-collections'], ['/collections/1', 'm-collections', 'd-collections'], ['/notifications', 'm-notifications', 'd-notifications'], -] +]; function renderAt(path: string) { return render( - ) + ); } beforeEach(() => { - resetAllStores() - vi.clearAllMocks() - isPhone.value = false + resetAllStores(); + vi.clearAllMocks(); + isPhone.value = false; // An admin passes the role check on /admin; every other route ignores it. useAuthStore.setState({ isLoading: false, @@ -81,31 +80,31 @@ beforeEach(() => { user: buildUser({ role: 'admin', mfa_enabled: true }), appRequireMfa: false, loadUser: vi.fn().mockResolvedValue(undefined), - }) + }); // /journey and /collections sit behind addons; without this they redirect to // the dashboard and the route under test never renders. - useAddonStore.setState({ loaded: true, isEnabled: () => true }) -}) + useAddonStore.setState({ loaded: true, isEnabled: () => true }); +}); describe('App — viewport routing', () => { it.each(ROUTES)( 'FE-COMP-APPVP-001: %s renders the mobile screen below the breakpoint', async (path, mobile, desktop) => { - isPhone.value = true - renderAt(path) + isPhone.value = true; + renderAt(path); - expect(await screen.findByText(mobile)).toBeInTheDocument() - expect(screen.queryByText(desktop)).not.toBeInTheDocument() + expect(await screen.findByText(mobile)).toBeInTheDocument(); + expect(screen.queryByText(desktop)).not.toBeInTheDocument(); } - ) + ); it.each(ROUTES)( 'FE-COMP-APPVP-002: %s renders the desktop page above the breakpoint', async (path, mobile, desktop) => { - renderAt(path) + renderAt(path); - expect(await screen.findByText(desktop)).toBeInTheDocument() - expect(screen.queryByText(mobile)).not.toBeInTheDocument() + expect(await screen.findByText(desktop)).toBeInTheDocument(); + expect(screen.queryByText(mobile)).not.toBeInTheDocument(); } - ) -}) + ); +}); diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 739bb1b71..f698a9a83 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,11 +1,11 @@ // FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039 -import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; +import { render, screen, waitFor } from '../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { useSettingsStore } from '../../store/settingsStore'; import { useAddonStore } from '../../store/addonStore'; +import { useSettingsStore } from '../../store/settingsStore'; import { ToastContainer } from '../shared/Toast'; import AddonManager from './AddonManager'; @@ -40,7 +40,7 @@ function llmAddon(config: Record = {}) { function modelsRoute(names: string[], seen?: (string | null)[]) { return http.get('/api/admin/llm/local/models', ({ request }) => { seen?.push(new URL(request.url).searchParams.get('baseUrl')); - return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) }); + return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) }); }); } @@ -71,9 +71,7 @@ beforeEach(() => { resetAllStores(); seedStore(useSettingsStore, { settings: { dark_mode: false } }); vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined); - server.use( - http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })) - ); + server.use(http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))); }); afterEach(() => { @@ -85,7 +83,7 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => { server.use( http.get('/api/admin/addons', async () => { - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); return HttpResponse.json({ addons: [] }); }) ); @@ -131,14 +129,15 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/addons', () => - HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] }) - ), - http.put('/api/admin/addons/todo', () => - HttpResponse.json({ success: true }) - ) + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })), + http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Todo List'); const toggleBtn = addonToggle('Todo List'); @@ -153,14 +152,15 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/addons', () => - HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] }) - ), - http.put('/api/admin/addons/todo', () => - HttpResponse.error() - ) + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })), + http.put('/api/admin/addons/todo', () => HttpResponse.error()) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Todo List'); await user.click(addonToggle('Todo List')); @@ -178,13 +178,9 @@ describe('AddonManager', () => { const user = userEvent.setup(); const mockToggle = vi.fn(); server.use( - http.get('/api/admin/addons', () => - HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }) - ) - ); - render( - + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })) ); + render(); await screen.findByText('Bag Tracking'); await user.click(subToggle('Bag Tracking')); expect(mockToggle).toHaveBeenCalled(); @@ -196,18 +192,14 @@ describe('AddonManager', () => { HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] }) ) ); - render( - - ); + render(); await screen.findByText('Lists'); expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument(); }); it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => { server.use( - http.get('/api/admin/addons', () => - HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }) - ) + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })) ); render(); await screen.findByText('Lists'); @@ -257,7 +249,12 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => { server.use(http.get('/api/admin/addons', () => HttpResponse.error())); - render(<>); + render( + <> + + + + ); await screen.findByText('Failed to update addon'); expect(screen.getByText('No addons available')).toBeInTheDocument(); @@ -273,11 +270,13 @@ describe('AddonManager', () => { }); it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => { - server.use(addonsRoute([ - buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }), - buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }), - buildAddon({ id: 'todo', name: 'Todo List' }), - ])); + server.use( + addonsRoute([ + buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }), + buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }), + buildAddon({ id: 'todo', name: 'Todo List' }), + ]) + ); render(); await screen.findByText('Todo List'); @@ -286,12 +285,32 @@ describe('AddonManager', () => { }); it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => { - server.use(addonsRoute([ - buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), - buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }), - buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }), - buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }), - ])); + server.use( + addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ + id: 'immich', + name: 'Immich', + description: 'Self-hosted photos', + type: 'photo_provider', + enabled: true, + }), + buildAddon({ + id: 'synologyphotos', + name: 'Synology Photos', + description: 'NAS photos', + type: 'photo_provider', + enabled: false, + }), + buildAddon({ + id: 'unsplash', + name: 'Unsplash', + description: 'Stock photos', + type: 'photo_provider', + enabled: false, + }), + ]) + ); render(); await screen.findByText('Immich'); @@ -313,14 +332,25 @@ describe('AddonManager', () => { server.use( addonsRoute([ buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), - buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }), + buildAddon({ + id: 'immich', + name: 'Immich', + description: 'Self-hosted photos', + type: 'photo_provider', + enabled: false, + }), ]), http.put('/api/admin/addons/immich', async ({ request }) => { body = await request.json(); return HttpResponse.json({ success: true }); - }), + }) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Immich'); await user.click(subToggle('Immich')); @@ -335,11 +365,22 @@ describe('AddonManager', () => { server.use( addonsRoute([ buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), - buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }), + buildAddon({ + id: 'unsplash', + name: 'Unsplash', + description: 'Stock photos', + type: 'photo_provider', + enabled: true, + }), ]), - http.put('/api/admin/addons/unsplash', () => HttpResponse.error()), + http.put('/api/admin/addons/unsplash', () => HttpResponse.error()) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Unsplash'); await user.click(subToggle('Unsplash')); @@ -349,11 +390,25 @@ describe('AddonManager', () => { }); it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => { - server.use(addonsRoute([ - buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), - buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }), - buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }), - ])); + server.use( + addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), + buildAddon({ + id: 'immich', + name: 'Immich', + description: 'Self-hosted photos', + type: 'photo_provider', + enabled: true, + }), + buildAddon({ + id: 'synologyphotos', + name: 'Synology Photos', + description: 'NAS photos', + type: 'photo_provider', + enabled: false, + }), + ]) + ); render(); await screen.findByText('Journey'); @@ -372,18 +427,29 @@ describe('AddonManager', () => { return HttpResponse.json({ addons: [ buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }), - buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }), + buildAddon({ + id: 'immich', + name: 'Immich', + description: 'Self-hosted photos', + type: 'photo_provider', + enabled: immichOn, + }), ], }); }), http.put('/api/admin/addons/journey', async ({ request }) => { - journeyOn = (await request.json() as { enabled: boolean }).enabled; + journeyOn = ((await request.json()) as { enabled: boolean }).enabled; // Journey off takes its providers with it, see admin.service.ts. if (!journeyOn) immichOn = false; return HttpResponse.json({ success: true }); - }), + }) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Immich'); await user.click(addonToggle('Journey')); @@ -406,9 +472,14 @@ describe('AddonManager', () => { loads += 1; return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] }); }), - http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })), + http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Todo List'); await user.click(addonToggle('Todo List')); @@ -419,12 +490,26 @@ describe('AddonManager', () => { }); it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => { - server.use(addonsRoute([ - buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), - buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), - buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }), - buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }), - ])); + server.use( + addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), + buildAddon({ + id: 'paperless', + name: 'Paperless-ngx', + description: 'Document archive', + type: 'document_provider', + enabled: true, + }), + buildAddon({ + id: 'nextcloud', + name: 'Nextcloud', + description: 'WebDAV files', + type: 'document_provider', + enabled: false, + }), + ]) + ); render(); await screen.findByText('Paperless-ngx'); @@ -438,10 +523,12 @@ describe('AddonManager', () => { }); it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => { - server.use(addonsRoute([ - buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }), - buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }), - ])); + server.use( + addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }), + ]) + ); render(); await screen.findByText('Documents'); @@ -464,12 +551,17 @@ describe('AddonManager', () => { }); }), http.put('/api/admin/addons/documents', async ({ request }) => { - documentsOn = (await request.json() as { enabled: boolean }).enabled; + documentsOn = ((await request.json()) as { enabled: boolean }).enabled; if (!documentsOn) paperlessOn = false; return HttpResponse.json({ success: true }); - }), + }) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Paperless-ngx'); await user.click(addonToggle('Documents')); @@ -500,9 +592,14 @@ describe('AddonManager', () => { http.put('/api/admin/addons/papra', async ({ request }) => { body = await request.json(); return HttpResponse.json({ success: true }); - }), + }) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Papra'); await user.click(subToggle('Papra')); @@ -521,7 +618,7 @@ describe('AddonManager', () => { , + /> ); await screen.findByText('Chat'); @@ -551,7 +648,7 @@ describe('AddonManager', () => { , + /> ); const links = await screen.findByText('Links'); @@ -566,7 +663,9 @@ describe('AddonManager', () => { await screen.findByText('AI Parsing'); // The catalog key now exists, so the tile shows the translation rather than falling // back to the English description the server sent. - expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument(); + expect( + screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose') + ).toBeInTheDocument(); expect(screen.queryByText('Provider')).not.toBeInTheDocument(); }); @@ -607,10 +706,8 @@ describe('AddonManager', () => { addonsRoute([llmAddon({ provider: 'local' })]), http.get('/api/admin/llm/local/models', () => { calls += 1; - return calls === 1 - ? HttpResponse.json({ error: 'down' }, { status: 500 }) - : HttpResponse.json({ models: [] }); - }), + return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] }); + }) ); render(); @@ -663,11 +760,16 @@ describe('AddonManager', () => { await delay(150); return new HttpResponse( '{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n', - { headers: { 'Content-Type': 'application/x-ndjson' } }, + { headers: { 'Content-Type': 'application/x-ndjson' } } ); - }), + }) + ); + render( + <> + + + ); - render(<>); await screen.findByText('No models installed yet — pull one below.'); await user.click(screen.getByRole('button', { name: 'Pull' })); @@ -685,15 +787,22 @@ describe('AddonManager', () => { const user = userEvent.setup(); const bodies: unknown[] = []; server.use( - addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]), + addonsRoute([ + llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true }), + ]), modelsRoute([]), http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })), http.put('/api/admin/addons/llm_parsing', async ({ request }) => { bodies.push(await request.json()); return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error(); - }), + }) + ); + render( + <> + + + ); - render(<>); await screen.findByText('No models installed yet — pull one below.'); await user.click(screen.getByRole('button', { name: 'Pull' })); @@ -719,9 +828,14 @@ describe('AddonManager', () => { http.put('/api/admin/addons/llm_parsing', async ({ request }) => { bodies.push(await request.json()); return HttpResponse.json({ success: true }); - }), + }) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Installed on the server'); expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument(); @@ -745,14 +859,27 @@ describe('AddonManager', () => { const user = userEvent.setup(); const bodies: unknown[] = []; server.use( - addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]), + addonsRoute([ + llmAddon({ + provider: 'local', + model: '', + baseUrl: 'http://ollama.lan:11434/v1', + apiKey: '', + multimodal: false, + }), + ]), modelsRoute([]), http.put('/api/admin/addons/llm_parsing', async ({ request }) => { bodies.push(await request.json()); return HttpResponse.json({ success: true }); - }), + }) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Installed on the server'); @@ -765,7 +892,13 @@ describe('AddonManager', () => { await screen.findByText('Saved'); // The stale local base URL must not ride along to Anthropic — it would hijack the endpoint. expect(bodies[0]).toEqual({ - config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' }, + config: { + provider: 'anthropic', + model: 'claude-haiku-4-5-20251001', + baseUrl: '', + apiKey: 'sk-ant-live', + vision: 'auto', + }, }); }); @@ -777,13 +910,20 @@ describe('AddonManager', () => { http.put('/api/admin/addons/llm_parsing', async ({ request }) => { bodies.push(await request.json()); return HttpResponse.json({ success: true }); - }), + }) + ); + render( + <> + + + ); - render(<>); // Stored off stays off; a cloud provider is told what Automatic means for it. await user.click(await screen.findByRole('button', { name: 'No' })); - expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument(); + expect( + screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.') + ).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Yes' })); await user.click(screen.getByRole('button', { name: 'Save' })); @@ -795,7 +935,9 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => { server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([])); render(); - expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument(); + expect( + await screen.findByText('Automatic asks the Ollama server whether this model reads images.') + ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument(); }); @@ -804,12 +946,20 @@ describe('AddonManager', () => { server.use( addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute([]), - http.post('/api/admin/llm/local/pull', () => new HttpResponse( - '{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', - { headers: { 'Content-Type': 'application/x-ndjson' } }, - )), + http.post( + '/api/admin/llm/local/pull', + () => + new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', { + headers: { 'Content-Type': 'application/x-ndjson' }, + }) + ) + ); + render( + <> + + + ); - render(<>); await screen.findByText('No models installed yet — pull one below.'); await user.click(screen.getByRole('button', { name: 'Pull' })); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index d7973a792..c29493d74 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,47 +1,93 @@ -import { useEffect, useId, useState, type ComponentType } from 'react' -import { adminApi } from '../../api/client' -import { useTranslation } from '../../i18n' -import { useAddonStore } from '../../store/addonStore' -import { useIsDark } from '../../hooks/useIsDark' -import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react' -import CustomSelect from '../shared/CustomSelect' -import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' -import EmptyState from '../shared/EmptyState' -import DawarichIcon from '../shared/DawarichIcon' -import AirTrailIcon from '../shared/AirTrailIcon' -import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons' -import AddonTile from './AddonTile' -import AddonSubRow from './AddonSubRow' -import { Tooltip } from '../shared/Tooltip' -import { fs } from '../shared/DialogShell' -import { EditorField, INPUT, Segmented } from '../shared/dialogParts' -import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit' +import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'; +import { + BarChart3, + Bookmark, + BookOpen, + Briefcase, + CalendarDays, + Cloud, + Compass, + FileText, + Globe, + Image, + Link2, + ListChecks, + Loader2, + Luggage, + MessageCircle, + Plane, + Puzzle, + RefreshCw, + Server, + Sparkles, + StickyNote, + Terminal, + Users, + Wallet, +} from 'lucide-react'; +import { useEffect, useId, useState, type ComponentType } from 'react'; +import { adminApi } from '../../api/client'; +import { useIsDark } from '../../hooks/useIsDark'; +import { useTranslation } from '../../i18n'; +import { useAddonStore } from '../../store/addonStore'; +import { SETTINGS_BUTTON_PRIMARY, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'; +import AirTrailIcon from '../shared/AirTrailIcon'; +import CustomSelect from '../shared/CustomSelect'; +import DawarichIcon from '../shared/DawarichIcon'; +import { fs } from '../shared/DialogShell'; +import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'; +import EmptyState from '../shared/EmptyState'; +import { useToast } from '../shared/Toast'; +import { Tooltip } from '../shared/Tooltip'; +import { EditorField, INPUT, Segmented } from '../shared/dialogParts'; +import AddonSubRow from './AddonSubRow'; +import AddonTile from './AddonTile'; // Keys are the `icon` column from the addons table (see server seeds.ts); anything // unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which // used to land on the fallback. const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles, + ListChecks, + Wallet, + FileText, + CalendarDays, + Puzzle, + Globe, + Briefcase, + Image, + Terminal, + Link2, + Compass, + BookOpen, + Plane, + Bookmark, + Users, + Sparkles, // Dawarich ships a brand mark rather than a lucide name, the same way the photo // providers below do. Keyed by the string in the addons table. Dawarich: DawarichIcon, -} +}; function ImmichIcon({ size = 14 }: { size?: number }) { return ( - + - ) + ); } function SynologyIcon({ size = 14 }: { size?: number }) { return ( - + - ) + ); } const PROVIDER_ICONS: Record> = { @@ -50,31 +96,31 @@ const PROVIDER_ICONS: Record> = { // The document providers' own marks, single-colour so they read as glyphs in // a row of lucide icons and invert with the theme. ...DOCUMENT_PROVIDER_ICONS, -} +}; interface Addon { - id: string - name: string - description: string - icon: string - type: string - enabled: boolean - config?: Record + id: string; + name: string; + description: string; + icon: string; + type: string; + enabled: boolean; + config?: Record; } interface ProviderOption { - key: string - label: string - description: string - enabled: boolean - toggle: () => Promise + key: string; + label: string; + description: string; + enabled: boolean; + toggle: () => Promise; } interface AddonIconProps { - name: string - size?: number + name: string; + size?: number; /** A switched-off addon greys its icon out, brand marks included. */ - enabled?: boolean + enabled?: boolean; } function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) { @@ -82,13 +128,13 @@ function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) { // sitting in the middle of it at glyph size, and they grey out when the addon // is off — the lucide glyphs get that from the slot's text colour, an // has to be told. - if (name === 'Dawarich') return + if (name === 'Dawarich') return ; // 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE // means every existing install still carries it — so the brand is keyed on // that rather than on a new name no row would ever have. - if (name === 'Plane') return - const Icon = ICON_MAP[name] || Puzzle - return + if (name === 'Plane') return ; + const Icon = ICON_MAP[name] || Puzzle; + return ; } /** What each type means, shown on the tile itself now that the sections are gone. The @@ -97,93 +143,116 @@ const TYPE_META: Record; labelK trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' }, global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' }, integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' }, -} +}; -interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean } +interface CollabFeatures { + chat: boolean; + notes: boolean; + links?: boolean; + polls: boolean; + whatsnext: boolean; +} const COLLAB_SUB_FEATURES = [ { key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' }, { key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' }, { key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' }, { key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' }, - { key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' }, -] as const + { + key: 'whatsnext', + icon: Sparkles, + titleKey: 'admin.collab.whatsnext.title', + subtitleKey: 'admin.collab.whatsnext.subtitle', + }, +] as const; -export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) { - const { t } = useTranslation() - const dark = useIsDark() - const toast = useToast() - const refreshGlobalAddons = useAddonStore(s => s.loadAddons) - const [addons, setAddons] = useState([]) - const [loading, setLoading] = useState(true) +export default function AddonManager({ + bagTrackingEnabled, + onToggleBagTracking, + collabFeatures, + onToggleCollabFeature, +}: { + bagTrackingEnabled?: boolean; + onToggleBagTracking?: () => void; + collabFeatures?: CollabFeatures; + onToggleCollabFeature?: (key: string) => void; +}) { + const { t } = useTranslation(); + const dark = useIsDark(); + const toast = useToast(); + const refreshGlobalAddons = useAddonStore((s) => s.loadAddons); + const [addons, setAddons] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { - void loadAddons().finally(() => setLoading(false)) - }, []) + void loadAddons().finally(() => setLoading(false)); + }, []); const loadAddons = async () => { try { - const data = await adminApi.addons() - setAddons(data.addons) + const data = await adminApi.addons(); + setAddons(data.addons); } catch (err: unknown) { - toast.error(t('admin.addons.toast.error')) + toast.error(t('admin.addons.toast.error')); } - } + }; /** Optimistic flip, per-row rollback on failure. Rolling the whole addons * snapshot back instead would undo a toggle the user hit in parallel. */ const handleToggle = async (addon: Addon) => { - const newEnabled = !addon.enabled - setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) + const newEnabled = !addon.enabled; + setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: newEnabled } : a))); try { - await adminApi.updateAddon(addon.id, { enabled: newEnabled }) + await adminApi.updateAddon(addon.id, { enabled: newEnabled }); } catch (err: unknown) { - setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) - toast.error(t('admin.addons.toast.error')) - return + setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: !newEnabled } : a))); + toast.error(t('admin.addons.toast.error')); + return; } - refreshGlobalAddons() + refreshGlobalAddons(); // Journey off disables every photo provider with it, and the response carries // only Journey. Without re-reading, switching Journey back on brings the shelf // up with providers the database has long since turned off. // Same for Documents: switching it off disables every document provider in // the database, and the response carries only the addon itself. - if (addon.id === 'journey' || addon.id === 'documents') await loadAddons() - toast.success(t('admin.addons.toast.updated')) - } + if (addon.id === 'journey' || addon.id === 'documents') await loadAddons(); + toast.success(t('admin.addons.toast.updated')); + }; const isPhotoProviderAddon = (addon: Addon) => { - return addon.type === 'photo_provider' - } + return addon.type === 'photo_provider'; + }; const isDocumentProviderAddon = (addon: Addon) => { - return addon.type === 'document_provider' - } + return addon.type === 'document_provider'; + }; const isPhotosAddon = (addon: Addon) => { - const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() - return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) - } + const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase(); + return ( + addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) + ); + }; - const photoProviderAddons = addons.filter(isPhotoProviderAddon) - const documentProviderAddons = addons.filter(isDocumentProviderAddon) - const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) - const globalAddons = addons.filter(a => a.type === 'global') - const integrationAddons = addons.filter(a => a.type === 'integration') + const photoProviderAddons = addons.filter(isPhotoProviderAddon); + const documentProviderAddons = addons.filter(isDocumentProviderAddon); + const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a)); + const globalAddons = addons.filter((a) => a.type === 'global'); + const integrationAddons = addons.filter((a) => a.type === 'integration'); const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ - key: provider.id, - label: provider.name, - description: provider.description, - enabled: provider.enabled, - toggle: () => handleToggle(provider), - })) + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleToggle(provider), + })); const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({ - key: provider.id, - label: provider.name, - description: provider.description, - enabled: provider.enabled, - toggle: () => handleToggle(provider), - })) + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleToggle(provider), + })); if (loading) { return ( @@ -192,7 +261,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
- ) + ); } /** Bag tracking, the collab features and the photo providers all hang off their @@ -208,11 +277,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, enabled={!!bagTrackingEnabled} onToggle={onToggleBagTracking} /> - ) + ); } if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) { - return COLLAB_SUB_FEATURES.map(feat => { - const Icon = feat.icon + return COLLAB_SUB_FEATURES.map((feat) => { + const Icon = feat.icon; return ( onToggleCollabFeature(feat.key)} /> - ) - }) + ); + }); } // Document providers are the Documents tile's shelf, the way photo providers // are Journey's. Unlike those they carry no credential form here: a document // connection belongs to a trip, not to a user, so it is entered in the trip's // file manager. The admin decides only whether a provider may be offered. if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) { - return documentProviderOptions.map(provider => { - const ProviderIcon = PROVIDER_ICONS[provider.key] + return documentProviderOptions.map((provider) => { + const ProviderIcon = PROVIDER_ICONS[provider.key]; return ( - ) - }) + ); + }); } if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) { - return providerOptions.map(provider => { - const ProviderIcon = PROVIDER_ICONS[provider.key] + return providerOptions.map((provider) => { + const ProviderIcon = PROVIDER_ICONS[provider.key]; return ( - ) - }) + ); + }); } // The AI-parsing settings hang off their tile like every other sub-feature, // just as a form instead of toggle rows. if (addon.id === 'llm_parsing' && addon.enabled) { - return + return ; } - return null - } + return null; + }; const tile = (addon: Addon) => { - const label = getAddonLabel(t, addon) + const label = getAddonLabel(t, addon); return ( {shelfFor(addon)} - ) - } + ); + }; /* One column per type, side by side, instead of three stacked sections. Stacked, each section opened its own set of columns and the tall tiles (Collab, Journey) set a @@ -291,59 +360,78 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, { key: 'trip', addons: tripAddons }, { key: 'global', addons: globalAddons }, { key: 'integration', addons: integrationAddons }, - ].filter(g => g.addons.length > 0) - const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length - const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + ].filter((g) => g.addons.length > 0); + const enabledCount = addons.filter( + (a) => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled + ).length; + const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length; const subtitle = ( {t('admin.addons.subtitleBefore')} - TREK + TREK {t('admin.addons.subtitleAfter')} - ) + ); return ( 0 ? ( - - - 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount} - - - ) : undefined} + badge={ + totalCount > 0 ? ( + + + 0 ? 'success' : 'neutral'}> + {enabledCount}/{totalCount} + + + + ) : undefined + } > {addons.length === 0 ? ( ) : (
- {groups.map(g => { - const meta = TYPE_META[g.key] + {groups.map((g) => { + const meta = TYPE_META[g.key]; return ( -
+
{g.addons.map(tile)}
- ) + ); })}
)}
- ) + ); } /** The head band of one type's column: icon tile, the type, its count and what it means. */ -function GroupHead({ icon: Icon, label, hint, addons, t }: { - icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }> - label: string - hint: string - addons: Addon[] - t: (key: string, params?: Record) => string +function GroupHead({ + icon: Icon, + label, + hint, + addons, + t, +}: { + icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>; + label: string; + hint: string; + addons: Addon[]; + t: (key: string, params?: Record) => string; }) { - const enabled = addons.filter(a => a.enabled).length + const enabled = addons.filter((a) => a.enabled).length; return (
@@ -351,31 +439,44 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
-

{label}

+

+ {label} +

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

{hint}

+

+ {hint} +

- ) + ); } -const MASKED = '••••••••' -const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint' -const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60' -const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1' +const MASKED = '••••••••'; +const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'; +const SMALL_BUTTON = + 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'; +const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'; /** Curated models the local extractor is tuned for, pullable via Ollama. The router drives * one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled * automatically, so the Qwen3 family works without any tuning. A host only needs one. */ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [ - { id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true }, -] + { + id: 'qwen3.5:4b', + label: 'Qwen3.5 — 4B', + note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', + recommended: true, + vision: true, + }, +]; /** * Instance-wide AI-parsing config. When set, applies to the whole instance and @@ -384,91 +485,99 @@ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended * provider, it also lists installed Ollama models and can pull NuExtract models. */ function LlmParsingConfig({ addon }: { addon: Addon }) { - const { t } = useTranslation() - const toast = useToast() - const cfg = (addon.config ?? {}) as Record - const [provider, setProvider] = useState((cfg.provider as string) ?? 'local') - const [model, setModel] = useState((cfg.model as string) ?? '') - const [baseUrl, setBaseUrl] = useState((cfg.baseUrl as string) ?? '') - const [apiKey, setApiKey] = useState((cfg.apiKey as string) ?? '') - const [vision, setVision] = useState(asLlmVision(cfg.vision)) - const [saving, setSaving] = useState(false) - const fieldId = useId() + const { t } = useTranslation(); + const toast = useToast(); + const cfg = (addon.config ?? {}) as Record; + const [provider, setProvider] = useState((cfg.provider as string) ?? 'local'); + const [model, setModel] = useState((cfg.model as string) ?? ''); + const [baseUrl, setBaseUrl] = useState((cfg.baseUrl as string) ?? ''); + const [apiKey, setApiKey] = useState((cfg.apiKey as string) ?? ''); + const [vision, setVision] = useState(asLlmVision(cfg.vision)); + const [saving, setSaving] = useState(false); + const fieldId = useId(); // Local-provider model management. - const [installed, setInstalled] = useState([]) - const [modelsErr, setModelsErr] = useState('') - const [loadingModels, setLoadingModels] = useState(false) - const [pulling, setPulling] = useState(null) - const [pullPct, setPullPct] = useState(0) - const [pullStatus, setPullStatus] = useState('') + const [installed, setInstalled] = useState([]); + const [modelsErr, setModelsErr] = useState(''); + const [loadingModels, setLoadingModels] = useState(false); + const [pulling, setPulling] = useState(null); + const [pullPct, setPullPct] = useState(0); + const [pullStatus, setPullStatus] = useState(''); - const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL - const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id)) + const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL; + const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id)); const loadModels = async () => { - if (provider !== 'local') return - setLoadingModels(true) - setModelsErr('') + if (provider !== 'local') return; + setLoadingModels(true); + setModelsErr(''); try { - const res = await adminApi.llmLocalModels(effectiveUrl) - setInstalled(res.models.map(m => m.name)) + const res = await adminApi.llmLocalModels(effectiveUrl); + setInstalled(res.models.map((m) => m.name)); } catch (e: unknown) { - setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server') - setInstalled([]) + setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server'); + setInstalled([]); } finally { - setLoadingModels(false) + setLoadingModels(false); } - } + }; // Load installed models when the local provider is active. useEffect(() => { - if (provider === 'local') void loadModels() + if (provider === 'local') void loadModels(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [provider]) + }, [provider]); const pull = async (id: string) => { - if (pulling) return - setPulling(id) - setPullPct(0) - setPullStatus('starting…') + if (pulling) return; + setPulling(id); + setPullPct(0); + setPullStatus('starting…'); try { await adminApi.llmLocalPull(effectiveUrl, id, (p) => { - if (p.error) throw new Error(p.error) - if (p.status) setPullStatus(p.status) - if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100)) - }) - toast.success('Model pulled') - setModel(id) - await loadModels() + if (p.error) throw new Error(p.error); + if (p.status) setPullStatus(p.status); + if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100)); + }); + toast.success('Model pulled'); + setModel(id); + await loadModels(); } catch (e: unknown) { - toast.error(e instanceof Error ? e.message : 'Pull failed') + toast.error(e instanceof Error ? e.message : 'Pull failed'); } finally { - setPulling(null) - setPullPct(0) - setPullStatus('') + setPulling(null); + setPullPct(0); + setPullStatus(''); } - } + }; const save = async () => { - setSaving(true) + setSaving(true); try { // Send the masked sentinel unchanged so the server keeps the stored key. - await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, vision } }) - toast.success('Saved') + await adminApi.updateAddon(addon.id, { + config: { + provider, + model: model.trim(), + baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), + apiKey, + vision, + }, + }); + toast.success('Saved'); } catch { - toast.error('Failed to save') + toast.error('Failed to save'); } finally { - setSaving(false) + setSaving(false); } - } + }; const providerOptions = [ { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' }, { value: 'openai', label: 'OpenAI', icon: }, { value: 'anthropic', label: 'Anthropic', icon: }, - ] - const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) })) + ]; + const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) })); /* Lives in the tile's shelf like the collab toggles, so it is one column of eyebrow fields: the band this used to be had a whole page width. */ @@ -479,29 +588,61 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { - setProvider(String(v))} options={providerOptions} /> + setProvider(String(v))} options={providerOptions} /> {provider !== 'anthropic' && ( - setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} /> + setBaseUrl(e.target.value)} + onBlur={loadModels} + placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} + /> )} - setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} /> + setApiKey(e.target.value)} + placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} + /> - setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} /> + setModel(e.target.value)} + placeholder={ + provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below' + } + /> - + label={t('settings.aiParsing.multimodal')} value={vision} - onChange={v => setVision(asLlmVision(v))} + onChange={(v) => setVision(asLlmVision(v))} options={visionOptions} fill /> @@ -511,22 +652,33 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { {provider === 'local' && (
- Installed on the server -
- {modelsErr &&

{modelsErr}

} + {modelsErr && ( +

+ {modelsErr} +

+ )} {!modelsErr && installed.length === 0 && !loadingModels && ( No models installed yet — pull one below. )} {installed.length > 0 && (
- {installed.map(name => ( - ) : ( - )} @@ -564,36 +732,47 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { {isPulling && (
-
+
+
+
+ {pullStatus} + {pullPct ? ` · ${pullPct}%` : ''}
-
{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
)}
- ) + ); })}
)}
-
- ) + ); } 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 && ( - )} @@ -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 && ( -
    { e.preventDefault(); void linkLocal() }} - className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-card p-3.5"> + { + e.preventDefault(); + void linkLocal(); + }} + className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-card p-3.5" + >
    - {t('admin.plugins.devLinkTitle')}} - htmlFor="plugin-dev-link" className="min-w-[220px] flex-1"> - setLinkPath(e.target.value)} spellCheck={false} - placeholder={t('admin.plugins.devLinkPathPlaceholder')} className={`${INPUT} font-mono`} /> + + + {t('admin.plugins.devLinkTitle')} + + } + htmlFor="plugin-dev-link" + className="min-w-[220px] flex-1" + > + setLinkPath(e.target.value)} + spellCheck={false} + placeholder={t('admin.plugins.devLinkPathPlaceholder')} + className={`${INPUT} font-mono`} + /> -
    @@ -896,68 +1196,138 @@ export default function AdminPluginsPanel() { {/* Toolbar: what to look at on the left, how to narrow and order it on the right. */}
    - setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} /> - + setView('installed')} + label={t('admin.plugins.installed')} + count={plugins.length} + /> +
    - } + } options={[ - ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], - ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], + ['all', t('admin.plugins.allTypes')], + ['widget', t('admin.plugins.type.widget')], + ['integration', t('admin.plugins.type.integration')], + ['page', t('admin.plugins.type.page')], ['trip-page', t('admin.plugins.type.trip-page')], ]} - valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} - onPick={v => setTypeFilter(v as TypeFilter)} /> + valueLabel={ + typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never) + } + onPick={(v) => setTypeFilter(v as TypeFilter)} + /> {view === 'installed' && ( - } + } options={[ - ['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')], - ['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')], + ['all', t('admin.plugins.allStatuses')], + ['on', t('admin.plugins.status.active')], + ['off', t('admin.plugins.stateOff')], + ['update', t('admin.plugins.filterUpdate')], + ['err', t('admin.plugins.status.error')], ]} valueLabel={statusLabel(statusFilter, t)} - onPick={v => setStatusFilter(v as StatusFilter)} /> + onPick={(v) => setStatusFilter(v as StatusFilter)} + /> )} - } - options={view === 'discover' - ? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]] - : [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} + } + options={ + view === 'discover' + ? [ + ['name', t('admin.plugins.sortName')], + ['recent', t('admin.plugins.sortRecent')], + ['downloads', t('admin.plugins.sortDownloads')], + ] + : [ + ['name', t('admin.plugins.sortName')], + ['recent', t('admin.plugins.sortRecent')], + ['updates', t('admin.plugins.sortUpdates')], + ] + } valueLabel={sortLabel(sort, t)} - onPick={v => setSort(v as SortKey)} /> + onPick={(v) => setSort(v as SortKey)} + />
    {view === 'discover' ? ( - + ) : plugins.length === 0 ? ( + action={ + - )} + } /> ) : ( <> {updatable.length > 0 && statusFilter !== 'err' && (
    - {t('admin.plugins.updatesAvailable', { count: updatable.length })} -
    @@ -966,22 +1336,38 @@ export default function AdminPluginsPanel() { {t('admin.plugins.noMatchInstalled')} ) : ( - {shownInstalled.map(p => ( - ( + toggle(p)} - onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} + onUpdate={() => runUpdate(p)} + onRestart={() => restart(p.id)} onChangeVersion={() => openVersionPicker(p)} onResume={() => void resumeUpdates(p)} - onReviewBlock={() => setSignatureBlock({ - subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, - code: p.updateBlock!.code, detail: p.updateBlock!.detail, - })} - onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} + onReviewBlock={() => + setSignatureBlock({ + subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, + code: p.updateBlock!.code, + detail: p.updateBlock!.detail, + }) + } + onErrors={() => openErrors(p.id)} + onEgress={() => openEgress(p.id)} onSettings={() => openInstanceSettings(p)} - onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> + onUninstall={() => { + setMenu(null); + setConfirmUninstall(p); + }} + /> ))} )} @@ -995,18 +1381,37 @@ export default function AdminPluginsPanel() { {/* Registry detail dialog */} {detailFor && ( - setDetailFor(null)} /> + setDetailFor(null)} + /> )} - {bypass.copy && } + {bypass.copy && ( + + )} {errorsFor && setErrorsFor(null)} />} {/* Operator-supplied egress hosts */} {egressFor && ( - void saveEgress(hosts)} onClose={() => setEgressFor(null)} t={t} /> + void saveEgress(hosts)} + onClose={() => setEgressFor(null)} + t={t} + /> )} {/* Instance-wide settings (the admin-owned scope:'instance' fields) */} @@ -1025,30 +1430,49 @@ export default function AdminPluginsPanel() { isOpen={!!confirmUninstall} onClose={() => setConfirmUninstall(null)} onConfirm={async () => { - const p = confirmUninstall!; setConfirmUninstall(null) - await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled')) + const p = confirmUninstall!; + setConfirmUninstall(null); + await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled')); }} title={t('admin.plugins.uninstallTitle')} message={t('admin.plugins.uninstallBody')} /> {versionPicker && ( - setVersionPicker(null)} /> + setVersionPicker(null)} + /> )} setConfirmDowngrade(null)} - onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }} + onConfirm={() => { + const d = confirmDowngrade!; + setConfirmDowngrade(null); + void runUpdate(d.plugin, d.version); + }} title={t('admin.plugins.downgradeTitle')} - message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })} + message={t('admin.plugins.downgradeBody', { + from: confirmDowngrade?.plugin.version ?? '', + to: confirmDowngrade?.version ?? '', + })} confirmLabel={t('admin.plugins.downgradeConfirm')} /> {signatureBlock && ( confirmRetrust(signatureBlock.subject.id, version, publicKey)} onClose={() => setSignatureBlock(null)} /> @@ -1056,7 +1480,8 @@ export default function AdminPluginsPanel() { {consentQueue[0] && ( { - const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1)) + const c = consentQueue[0]; + setConsentQueue((qq) => qq.slice(1)); // consent:true — the ONLY path that may widen a plugin's granted rights. - await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated')) + await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated')); + }} + onLater={() => { + setConsentQueue((qq) => qq.slice(1)); + toast.success(t('admin.plugins.updateKeptOff')); }} - onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }} /> )} {depResolve && ( resolveDependency(depResolve.plugin, depId, constraint)} onClose={() => setDepResolve(null)} /> )}
    - ) + ); } 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 ( - ) + ); } /** "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 ( - - ) + ); } /** 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 (
    - - {open && createPortal( -
    - {options.map(([v, lbl]) => ( - - ))} -
    , - document.body, - )} + {open && + createPortal( +
    + {options.map(([v, 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 })} -
    @@ -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 ?? '' })} -
    )} {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 && ( - - - - )} -
    + {p.operatorEgress && ( + + + + )} +
    + ) )} {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 && ( - )} - + {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
    - - {menuOpen && createPortal( -
    - {p.enabled === 1 && ( - } label={t('admin.plugins.restart')} onClick={onRestart} /> - )} - {/* A plugin gets the item if it declares scope:'instance' fields OR actions — + {menuOpen && + createPortal( +
    + {p.enabled === 1 && ( + } label={t('admin.plugins.restart')} onClick={onRestart} /> + )} + {/* A plugin gets the item if it declares scope:'instance' fields OR actions — an action-only plugin still needs the dialog to run its buttons. */} - {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( - } label={t('admin.plugins.instanceSettings')} onClick={onSettings} /> - )} - } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> - {/* Only a plugin that DECLARED operatorEgress gets the item — an admin must + {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( + } + label={t('admin.plugins.instanceSettings')} + onClick={onSettings} + /> + )} + } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> + {/* Only a plugin that DECLARED operatorEgress gets the item — an admin must never be invited to widen egress for a plugin that didn't ask for it (same rule the row's egress chip follows). */} - {p.operatorEgress && ( - } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> - )} - {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} - {isRegistrySourced(p.source_repo) && ( - } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> - )} - {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( - <> - setMenu(null)} - className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}> - {t('admin.plugins.sourceRepo')} - - setMenu(null)} - className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}> - {t('admin.plugins.reportIssue')} - - - )} -
    - } label={t('common.delete')} danger onClick={onUninstall} /> -
    , - document.body, - )} + {p.operatorEgress && ( + } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + )} + {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} + {isRegistrySourced(p.source_repo) && ( + } + label={t('admin.plugins.changeVersion')} + onClick={onChangeVersion} + /> + )} + {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( + <> + setMenu(null)} + className={`${MENU_ITEM} text-content-secondary`} + style={fs(12.5, 'body')} + > + + + {' '} + {t('admin.plugins.sourceRepo')} + + setMenu(null)} + className={`${MENU_ITEM} text-content-secondary`} + style={fs(12.5, 'body')} + > + + + {' '} + {t('admin.plugins.reportIssue')} + + + )} +
    + } label={t('common.delete')} danger onClick={onUninstall} /> +
    , + 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 ( - - ) + ); } 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)} )}
    -
    - ) + ); })}
    - ) + ); } // 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
      {children}
    + return ( +
      + {children} +
    + ); } /** 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={ - - )}> + } + > {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`} /> -
    - {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 (
    - - {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 ? ( - ) : ( - {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 })}
    -
    ))}
    - {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={ + - )} + } >
    @@ -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 @@ -145,7 +154,7 @@ 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 || '—'}
    - ) + ); } 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 - - ) + ); - 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)}
    - - -
    - ) + ); })} - ) + ); } - 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. */}
    - ) + ); })} )} @@ -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 ( -
    -