From ce34617147156a9da8ec78edfba377dabe6b77ab Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:01:38 +0100 Subject: [PATCH] feat: turn plugins an admin installs on for every user, who can turn them off The #1327 store test now serves its optional plugin with defaultOn false: it checks that every plugin is listed when IndexedDB fails, and a plugin the admin didn't force is on by default now. --- .../admin/__tests__/plugin-user-mode.test.tsx | 78 +++++++++++++ app/(main)/admin/_pages/extension-detail.tsx | 109 ++++++++++++++---- app/(main)/admin/_pages/plugin-type-label.ts | 13 +++ .../extensions/[kind]/[id]/rollback/route.ts | 1 + app/api/admin/marketplace/route.ts | 1 + app/api/admin/plugins/__tests__/route.test.ts | 32 ++++- app/api/admin/plugins/route.ts | 14 ++- app/api/plugins/__tests__/route.test.ts | 44 +++++++ app/api/plugins/route.ts | 3 + .../__tests__/plugins-settings-card.test.tsx | 42 +++++++ components/settings/plugins-settings.tsx | 14 ++- lib/__tests__/plugin-store.test.ts | 68 ++++++++++- lib/admin/plugin-registry.ts | 10 +- lib/plugin-types.ts | 12 ++ stores/plugin-store.ts | 34 ++++-- 15 files changed, 432 insertions(+), 43 deletions(-) create mode 100644 app/(main)/admin/__tests__/plugin-user-mode.test.tsx create mode 100644 app/(main)/admin/_pages/plugin-type-label.ts create mode 100644 app/api/plugins/__tests__/route.test.ts create mode 100644 components/settings/__tests__/plugins-settings-card.test.tsx diff --git a/app/(main)/admin/__tests__/plugin-user-mode.test.tsx b/app/(main)/admin/__tests__/plugin-user-mode.test.tsx new file mode 100644 index 000000000..da587fd9f --- /dev/null +++ b/app/(main)/admin/__tests__/plugin-user-mode.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; + +// Decision 5 (usability 2026-11-04): an admin's plugin is on for every user +// by default, users may turn it off unless the admin says "always", and the +// page says which of the three applies. + +const calls: Array<{ url: string; method: string; body: Record }> = []; +const routes: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + calls.push({ url, method, body: JSON.parse(String(init?.body ?? '{}')) }); + const data = method === 'GET' ? routes[url.split('?')[0]] ?? {} : { ok: true }; + return new Response(JSON.stringify(data), { status: 200, headers: { 'content-type': 'application/json' } }); + }), + withBasePath: (p: string) => p, +})); +vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children })); +vi.mock('../_pages/use-extension-icons', () => ({ useExtensionIcons: () => () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: () => null })); + +import { ExtensionDetailPage } from '../_pages/extension-detail'; + +function serve(entry: Record) { + routes['/api/admin/plugins'] = [{ + id: 'send-checks', name: 'Send Checks', version: '1.0.0', enabled: true, author: 'x', description: '', type: 'hook', + permissions: ['email:send'], ...entry, + }]; +} + +beforeEach(() => { + calls.length = 0; +}); +afterEach(() => { + useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null }); +}); + +describe('what users get from an installed plugin', () => { + it('says a new install is on for everyone and users can turn it off', async () => { + serve({}); + render(); + const choice = await screen.findByRole('combobox', { name: 'For users' }); + expect((choice as HTMLSelectElement).value).toBe('default-on'); + expect(screen.getByText(/Every user gets it turned on\. Anyone can turn it off/)).toBeTruthy(); + expect(screen.queryByText('Force on for everyone')).toBeNull(); + }); + + it('shows a forced plugin as "always" and a default-off one as such', async () => { + serve({ forceEnabled: true }); + const { unmount } = render(); + expect(((await screen.findByRole('combobox', { name: 'For users' })) as HTMLSelectElement).value).toBe('always'); + unmount(); + serve({ defaultOff: true }); + render(); + expect(((await screen.findByRole('combobox', { name: 'For users' })) as HTMLSelectElement).value).toBe('default-off'); + }); + + it('saves the choice as forceEnabled and defaultOff', async () => { + serve({}); + render(); + const choice = await screen.findByRole('combobox', { name: 'For users' }); + fireEvent.change(choice, { target: { value: 'default-off' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(calls.filter((c) => c.method === 'PATCH')).toEqual([ + expect.objectContaining({ url: '/api/admin/plugins', body: { id: 'send-checks', defaultOff: true } }), + ])); + }); + + it('names permissions in words, not only by id', async () => { + serve({}); + render(); + // The next-intl mock returns the key. + expect(await screen.findByText('email_send.title')).toBeTruthy(); + }); +}); diff --git a/app/(main)/admin/_pages/extension-detail.tsx b/app/(main)/admin/_pages/extension-detail.tsx index 1d39bd725..7ff77a324 100644 --- a/app/(main)/admin/_pages/extension-detail.tsx +++ b/app/(main)/admin/_pages/extension-detail.tsx @@ -9,7 +9,7 @@ import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; import { navigateAdmin } from '@/hooks/use-admin-navigation'; import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers'; import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; -import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; +import { SettingsSection, SettingItem, ToggleSwitch, Select } from '@/components/settings/settings-section'; import { Button } from '@/components/ui/button'; import { AdminPageHeader } from '@/components/admin/page-header'; import { AdminLink } from '@/components/admin/admin-link'; @@ -20,6 +20,9 @@ import { inputClass } from '@/components/admin/config-items'; import { ExtensionIcon } from '@/components/admin/extension-icon'; import { cn } from '@/lib/utils'; import { useExtensionIcons } from './use-extension-icons'; +import { pluginTypeLabel } from './plugin-type-label'; +import { useTranslations } from 'next-intl'; +import { permissionKey } from '@/lib/plugin-sandbox/permission-text'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { ChangelogNotes } from '@/components/admin/changelog'; @@ -49,6 +52,8 @@ export interface ExtensionEntry { variants?: string[]; enabled: boolean; forceEnabled?: boolean; + /** Plugins: users get it off until they turn it on. */ + defaultOff?: boolean; permissions?: string[]; installedAt: string; updatedAt: string; @@ -66,6 +71,46 @@ export interface ExtensionEntry { type Kind = 'plugin' | 'theme'; +/** + * A permission in the words users see when they're asked (the consent + * dialog's catalogue), with its id small beside it for those who know it. + */ +function PermissionText({ perm }: { perm: string }) { + const t = useTranslations('plugins.permissions'); + return ( + + {t(`${permissionKey(perm)}.title`)}{' '} + {perm} + + ); +} + +/** What users get from an installed plugin, as one choice (PATCH: forceEnabled + defaultOff). */ +export type PluginUserMode = 'default-on' | 'always' | 'default-off'; + +export const PLUGIN_USER_MODES: ReadonlyArray<{ value: PluginUserMode; label: string; description: string }> = [ + { + value: 'default-on', + label: 'On for everyone, users can turn it off', + description: 'Every user gets it turned on. Anyone can turn it off for their own account in Settings > Add-ons.', + }, + { + value: 'always', + label: 'On for everyone, always', + description: 'Every user gets it turned on and can\'t turn it off.', + }, + { + value: 'default-off', + label: 'Off until users turn it on', + description: 'Users see it in Settings > Add-ons, turned off, and can turn it on for their own account.', + }, +]; + +export function pluginUserMode(entry: { forceEnabled?: boolean; defaultOff?: boolean }): PluginUserMode { + if (entry.forceEnabled) return 'always'; + return entry.defaultOff ? 'default-off' : 'default-on'; +} + const KIND_LABEL: Record = { plugin: { one: 'plugin', many: 'Plugins', endpoint: '/api/admin/plugins' }, theme: { one: 'theme', many: 'Themes', endpoint: '/api/admin/themes' }, @@ -134,11 +179,17 @@ export function ExtensionDetailPage({ kind, id }: Props) { }, [schema, savedValues]); const dirty = Object.keys(savedValues).some((key) => (formValues[key] ?? '') !== savedValues[key]); - // On/off and "force on" wait for Save like the settings below. - const [availabilityDraft, setAvailabilityDraft] = useState<{ enabled?: boolean; forceEnabled?: boolean }>({}); + // On/off and what users get wait for Save like the settings below. + const [availabilityDraft, setAvailabilityDraft] = useState<{ enabled?: boolean; forceEnabled?: boolean; defaultOff?: boolean }>({}); const shownForced = availabilityDraft.forceEnabled ?? !!entry?.forceEnabled; + const shownDefaultOff = availabilityDraft.defaultOff ?? !!entry?.defaultOff; + const shownMode = pluginUserMode({ forceEnabled: shownForced, defaultOff: shownDefaultOff }); const shownEnabled = shownForced || (availabilityDraft.enabled ?? !!entry?.enabled); - const availabilityDirty = !!entry && (shownEnabled !== entry.enabled || shownForced !== !!entry.forceEnabled); + const availabilityDirty = !!entry && ( + shownEnabled !== entry.enabled + || shownForced !== !!entry.forceEnabled + || (kind === 'plugin' && shownDefaultOff !== !!entry.defaultOff) + ); useAdminUnsavedChanges(`${kind}-config`, dirty || availabilityDirty); async function saveConfig() { @@ -221,9 +272,10 @@ export function ExtensionDetailPage({ kind, id }: Props) { if (!entry) return true; const body: Record = {}; if (shownForced !== !!entry.forceEnabled) body.forceEnabled = shownForced; + if (kind === 'plugin' && shownDefaultOff !== !!entry.defaultOff) body.defaultOff = shownDefaultOff; if (shownEnabled !== entry.enabled) body.enabled = shownEnabled; const ok = await patch(body, 'availability'); - if (ok) setEntry((prev) => (prev ? { ...prev, enabled: shownEnabled, forceEnabled: shownForced } : prev)); + if (ok) setEntry((prev) => (prev ? { ...prev, enabled: shownEnabled, forceEnabled: shownForced, ...(kind === 'plugin' ? { defaultOff: shownDefaultOff } : {}) } : prev)); setAvailabilityDraft({}); return ok; } @@ -329,14 +381,14 @@ export function ExtensionDetailPage({ kind, id }: Props) { {entry.name} v{entry.version} - {entry.forceEnabled && }>Forced on} + {entry.forceEnabled && }>Always on} {entry.dev && Dev} )} description={( <> by {entry.author || 'unknown'} - {entry.type && <> · {entry.type}} + {entry.type && <> · {kind === 'plugin' ? pluginTypeLabel(entry.type) : entry.type}} {entry.variants && entry.variants.length > 0 && <> · {entry.variants.join(', ')}} {entry.installedAt && <> · installed {new Date(entry.installedAt).toLocaleDateString()}} @@ -366,18 +418,35 @@ export function ExtensionDetailPage({ kind, id }: Props) {
- - { setMessage(null); setAvailabilityDraft((prev) => ({ ...prev, forceEnabled: v })); }} - /> - + {kind === 'plugin' ? ( + m.value === shownMode)!.description + : 'The plugin is off for everyone. Turn it on at the top of this page first.'} + > +