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.
This commit is contained in:
Linus Rath
2026-11-04 08:01:38 +01:00
parent 5a020cf2f9
commit ce34617147
15 changed files with 432 additions and 43 deletions
@@ -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<string, unknown> }> = [];
const routes: Record<string, unknown> = {};
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<string, unknown>) {
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(<ExtensionDetailPage kind="plugin" id="send-checks" />);
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(<ExtensionDetailPage kind="plugin" id="send-checks" />);
expect(((await screen.findByRole('combobox', { name: 'For users' })) as HTMLSelectElement).value).toBe('always');
unmount();
serve({ defaultOff: true });
render(<ExtensionDetailPage kind="plugin" id="send-checks" />);
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(<ExtensionDetailPage kind="plugin" id="send-checks" />);
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(<ExtensionDetailPage kind="plugin" id="send-checks" />);
// The next-intl mock returns the key.
expect(await screen.findByText('email_send.title')).toBeTruthy();
});
});
+89 -20
View File
@@ -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 (
<span className="min-w-0 flex-1">
<span className="text-sm text-foreground">{t(`${permissionKey(perm)}.title`)}</span>{' '}
<code className="font-mono text-[0.6875rem] text-muted-foreground">{perm}</code>
</span>
);
}
/** 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<Kind, { one: string; many: string; endpoint: string }> = {
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<string, unknown> = {};
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) {
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
{entry.name}
<span className="text-sm font-normal text-muted-foreground">v{entry.version}</span>
{entry.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Forced on</Badge>}
{entry.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Always on</Badge>}
{entry.dev && <Badge tone="info" title="Loaded from the dev plugins directory">Dev</Badge>}
</span>
)}
description={(
<>
by {entry.author || 'unknown'}
{entry.type && <> &middot; {entry.type}</>}
{entry.type && <> &middot; {kind === 'plugin' ? pluginTypeLabel(entry.type) : entry.type}</>}
{entry.variants && entry.variants.length > 0 && <> &middot; {entry.variants.join(', ')}</>}
{entry.installedAt && <> &middot; installed {new Date(entry.installedAt).toLocaleDateString()}</>}
</>
@@ -366,18 +418,35 @@ export function ExtensionDetailPage({ kind, id }: Props) {
<div className="space-y-10">
<SettingsSection id="availability" title="Availability" description={`Whether users get this ${words.one} and whether they can turn it off.`}>
<SettingItem
label="Force on for everyone"
description={kind === 'plugin'
? 'Users cannot turn it off in their settings. Turning this on also enables the plugin.'
: 'Users cannot deactivate it. Turning this on also enables the theme.'}
>
<ToggleSwitch
checked={shownForced}
disabled={busy === 'availability'}
onChange={(v) => { setMessage(null); setAvailabilityDraft((prev) => ({ ...prev, forceEnabled: v })); }}
/>
</SettingItem>
{kind === 'plugin' ? (
<SettingItem
label="For users"
description={shownEnabled
? PLUGIN_USER_MODES.find((m) => m.value === shownMode)!.description
: 'The plugin is off for everyone. Turn it on at the top of this page first.'}
>
<Select
value={shownMode}
disabled={busy === 'availability'}
options={PLUGIN_USER_MODES.map(({ value, label }) => ({ value, label }))}
onChange={(value) => {
setMessage(null);
setAvailabilityDraft((prev) => ({ ...prev, forceEnabled: value === 'always', defaultOff: value === 'default-off' }));
}}
/>
</SettingItem>
) : (
<SettingItem
label="Always on for everyone"
description="Users can't deactivate it. Turning this on also enables the theme."
>
<ToggleSwitch
checked={shownForced}
disabled={busy === 'availability'}
onChange={(v) => { setMessage(null); setAvailabilityDraft((prev) => ({ ...prev, forceEnabled: v })); }}
/>
</SettingItem>
)}
<SettingItem label="Source" description={entry.source?.kind === 'marketplace' ? 'Installed from the extension directory; updates show up in the marketplace.' : 'Installed from an uploaded ZIP.'}>
{entry.source?.kind === 'marketplace' ? (
<AdminLink href={adminPath('marketplace', entry.source.slug)} className="inline-flex items-center gap-1.5 text-sm text-foreground hover:underline">
@@ -400,7 +469,7 @@ export function ExtensionDetailPage({ kind, id }: Props) {
{risky
? <ShieldAlert className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-label="High risk" />
: <Shield className="h-4 w-4 shrink-0 text-muted-foreground/60" />}
<code className="font-mono text-xs text-foreground">{perm}</code>
<PermissionText perm={perm} />
{risky && <Badge tone="warning">High risk</Badge>}
</li>
);
@@ -0,0 +1,13 @@
/**
* What a plugin's manifest type means, for the admin pages: the raw values
* (`ui-extension`, `hook`) are developer words.
*/
export function pluginTypeLabel(type: string | null | undefined): string {
switch (type) {
case 'ui-extension': return 'Adds to the interface';
case 'hook': return 'Works in the background';
case 'sidebar-app': return 'Adds an app to the sidebar';
case 'theme': return 'Theme';
default: return 'Plugin';
}
}
@@ -58,6 +58,7 @@ export async function POST(
id,
enabled: current.enabled,
...(current.forceEnabled !== undefined ? { forceEnabled: current.forceEnabled } : {}),
defaultOff: current.defaultOff === true ? true : undefined,
installedAt: current.installedAt,
updatedAt: now,
};
+1
View File
@@ -370,6 +370,7 @@ export async function POST(request: NextRequest) {
...(existingPlugin?.forceEnabled !== undefined
? { forceEnabled: existingPlugin.forceEnabled }
: {}),
...(existingPlugin?.defaultOff ? { defaultOff: true } : {}),
installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now,
...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}),
+31 -1
View File
@@ -29,6 +29,13 @@ vi.mock('@/lib/admin/plugin-registry', () => ({
getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null),
getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })),
deletePlugin: vi.fn(async (id: string) => registry.delete(id)),
updatePluginMeta: vi.fn(async (id: string, updates: Record<string, unknown>) => {
const current = registry.get(id);
if (!current) return null;
const next = { ...current, ...updates };
registry.set(id, next);
return next;
}),
}));
vi.mock('@/lib/admin/plugin-config', () => ({
deletePluginConfig: vi.fn(async (id: string) => { removedConfig.push(id); }),
@@ -39,7 +46,7 @@ vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({
invalidateFrameOriginsCache: vi.fn(),
}));
import { DELETE, POST } from '../route';
import { DELETE, PATCH, POST } from '../route';
const MANIFEST = {
id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd',
@@ -111,6 +118,29 @@ describe('re-uploading and deleting', () => {
expect(registry.get('demo-plugin')).toMatchObject({ version: '1.1.0', enabled: false, forceEnabled: true });
});
it('keeps "Off until users turn it on" when a new version is uploaded', async () => {
registry.set('demo-plugin', { id: 'demo-plugin', enabled: true, defaultOff: true });
await upload({ 'manifest.json': JSON.stringify({ ...MANIFEST, version: '1.1.0' }), 'index.js': '' });
expect(registry.get('demo-plugin')).toMatchObject({ version: '1.1.0', defaultOff: true });
});
it('stores what users get while they haven\'t chosen', async () => {
registry.set('demo-plugin', { id: 'demo-plugin', enabled: true });
const patch = (body: Record<string, unknown>) => PATCH(new NextRequest('http://localhost/api/admin/plugins', {
method: 'PATCH',
body: JSON.stringify({ id: 'demo-plugin', ...body }),
headers: { 'content-type': 'application/json' },
}));
expect((await patch({ defaultOff: true })).status).toBe(200);
expect(registry.get('demo-plugin')).toMatchObject({ defaultOff: true });
expect((await patch({ defaultOff: false })).status).toBe(200);
expect(registry.get('demo-plugin')).toMatchObject({ defaultOff: false });
expect((await patch({ defaultOff: 'yes' })).status).toBe(400);
});
it('drops the config and the forced flag when a plugin is deleted', async () => {
registry.set('demo-plugin', { id: 'demo-plugin' });
policy = { forceEnabledPlugins: ['demo-plugin', 'other'] };
+9 -5
View File
@@ -117,6 +117,7 @@ export async function POST(request: NextRequest) {
entrypoint: manifest.entrypoint,
enabled: existing?.enabled ?? true,
...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}),
...(existing?.defaultOff ? { defaultOff: true } : {}),
...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}),
...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
...(manifest.locales ? { locales: manifest.locales } : {}),
@@ -147,7 +148,9 @@ export async function POST(request: NextRequest) {
/**
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
* Body: { id: string, enabled: boolean }
* Body: { id: string, enabled?: boolean, forceEnabled?: boolean, defaultOff?: boolean }
* `defaultOff` decides what users get while they haven't chosen: off when
* true, on otherwise. `forceEnabled` keeps it on for everyone.
*/
export async function PATCH(request: NextRequest) {
try {
@@ -155,13 +158,13 @@ export async function PATCH(request: NextRequest) {
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id, enabled, forceEnabled } = await request.json();
const { id, enabled, forceEnabled, defaultOff } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
}
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean' && typeof defaultOff !== 'boolean') {
return NextResponse.json({ error: 'enabled, forceEnabled or defaultOff must be a boolean' }, { status: 400 });
}
// Turning one off stays possible: the policy only stops what would run.
@@ -170,9 +173,10 @@ export async function PATCH(request: NextRequest) {
if (refused) return refused;
}
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
const updates: { enabled?: boolean; forceEnabled?: boolean; defaultOff?: boolean } = {};
if (typeof enabled === 'boolean') updates.enabled = enabled;
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
if (typeof defaultOff === 'boolean') updates.defaultOff = defaultOff;
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
let updated = await updatePluginMeta(id, updates);
+44
View File
@@ -0,0 +1,44 @@
/**
* @vitest-environment node
*/
import { describe, it, expect, vi } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: vi.fn(async () => true) }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
getPolicy: vi.fn(() => ({ forceEnabledPlugins: ['policy-forced'] })),
},
}));
const base = { name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook', permissions: [], entrypoint: 'index.js', enabled: true, installedAt: '', updatedAt: '' };
vi.mock('@/lib/admin/plugin-registry', () => ({
getPluginRegistry: vi.fn(async () => ({
plugins: [
{ ...base, id: 'default-on' },
{ ...base, id: 'default-off', defaultOff: true },
{ ...base, id: 'forced', forceEnabled: true, defaultOff: true },
{ ...base, id: 'policy-forced' },
],
})),
getThemeRegistry: vi.fn(async () => ({ themes: [] })),
}));
vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: vi.fn(async () => false) }));
vi.mock('@/lib/desktop/server-policy', () => ({ pluginRefusedByDevicePolicy: () => false }));
import { GET } from '../route';
describe('GET /api/plugins', () => {
it('tells clients what users get while they haven\'t chosen', async () => {
const res = await GET(new NextRequest('https://webmail.example/api/plugins'));
const { plugins } = await res.json() as { plugins: Array<{ id: string; defaultOn: boolean; forceEnabled: boolean }> };
const byId = Object.fromEntries(plugins.map((p) => [p.id, { defaultOn: p.defaultOn, forceEnabled: p.forceEnabled }]));
expect(byId).toEqual({
'default-on': { defaultOn: true, forceEnabled: false },
'default-off': { defaultOn: false, forceEnabled: false },
forced: { defaultOn: false, forceEnabled: true },
'policy-forced': { defaultOn: true, forceEnabled: true },
});
});
});
+3
View File
@@ -61,6 +61,9 @@ export async function GET(request: NextRequest) {
// and during pending policy saves; OR'ing here unifies the signal so
// the client's auto-enable path triggers consistently.
forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id),
// What a user who hasn't chosen gets: on unless the admin picked
// "Off until users turn it on".
defaultOn: !('defaultOff' in p && p.defaultOff === true),
// Content hash + updatedAt let clients detect re-uploads even when
// the manifest version is unchanged.
bundleHash: p.bundleHash,
@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'en' }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
import { PluginsSettings } from '../plugins-settings';
import { usePluginStore } from '@/stores/plugin-store';
import { usePolicyStore } from '@/stores/policy-store';
import type { InstalledPlugin } from '@/lib/plugin-types';
const plugin = (overrides: Partial<InstalledPlugin>) => ({
id: 'send-checks', name: 'Send Checks', version: '1.0.0', author: 'Bulwark',
description: 'Checks for IBAN and card numbers before a message is sent.',
type: 'hook', permissions: [], entrypoint: 'index.js', enabled: false, status: 'installed', managed: true, settings: {},
...overrides,
}) as InstalledPlugin;
function show(plugins: InstalledPlugin[]) {
usePolicyStore.setState({ loaded: true, isFeatureEnabled: () => true, isPluginForceEnabled: () => false } as never);
usePluginStore.setState({ plugins, initialized: true, initializePlugins: vi.fn() } as never);
render(<PluginsSettings />);
}
describe('a plugin in the user\'s list', () => {
it('says what it does without opening it', () => {
show([plugin({})]);
expect(screen.getByTestId('plugin-description')).toHaveTextContent('Checks for IBAN and card numbers before a message is sent.');
});
it('has no "Off" or "On" word next to its switch', () => {
show([plugin({}), plugin({ id: 'b', name: 'B', enabled: true, status: 'running' })]);
expect(screen.queryByText('status_installed')).toBeNull();
expect(screen.queryByText('status_running')).toBeNull();
expect(screen.getAllByRole('switch')).toHaveLength(2);
});
it('still says when something is wrong', () => {
show([plugin({ status: 'error', error: 'auto-disabled' })]);
expect(screen.getByText('status_error')).toBeInTheDocument();
});
});
+9 -5
View File
@@ -257,9 +257,12 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-sm font-medium text-foreground">{plugin.name}</span>
<span className={cn('rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium', STATUS_COLORS[plugin.status])}>
{t(`status_${plugin.status}`)}
</span>
{/* The switch says on or off; only a problem gets a word of its own. */}
{plugin.status === 'error' && (
<span className={cn('rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium', STATUS_COLORS.error)}>
{t('status_error')}
</span>
)}
{isForceEnabled && (
<span className="flex items-center gap-0.5 rounded-full bg-amber-100 px-1.5 py-0.5 text-[0.625rem] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<Lock className="h-2.5 w-2.5" /> {t('badge_forced')}
@@ -279,6 +282,9 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo
</span>
)}
</div>
{plugin.description && (
<p data-testid="plugin-description" className="mt-0.5 line-clamp-2 text-xs text-foreground/80">{plugin.description}</p>
)}
<div className="mt-0.5 flex items-center gap-2">
<span className="truncate text-xs text-muted-foreground">{plugin.author}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
@@ -299,8 +305,6 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo
<div className="space-y-3 border-t border-border p-3">
{isForceEnabled && <p className="text-xs text-amber-600 dark:text-amber-400">{t('forced_note')}</p>}
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
{errorText && (
<div className="flex items-start gap-2 rounded bg-destructive/10 p-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
+67 -1
View File
@@ -199,7 +199,7 @@ describe('usePluginStore', () => {
it('lists every server plugin when IndexedDB cannot be written (#1327)', async () => {
mocks.apiFetch.mockResolvedValue({
ok: true,
json: async () => ({ plugins: [serverPlugin, { ...serverPlugin, id: 'optional-plugin', forceEnabled: false }], themes: [] }),
json: async () => ({ plugins: [serverPlugin, { ...serverPlugin, id: 'optional-plugin', forceEnabled: false, defaultOn: false }], themes: [] }),
});
vi.mocked(pluginStorage.codeIds).mockRejectedValue(new Error('IndexedDB unavailable'));
vi.mocked(pluginStorage.saveCode).mockRejectedValueOnce(new Error('IndexedDB unavailable'));
@@ -214,6 +214,72 @@ describe('usePluginStore', () => {
expect(plugins[1]).toMatchObject({ managed: true, enabled: false });
});
describe('what users get from an admin\'s plugin that isn\'t forced', () => {
const optional = { ...serverPlugin, id: 'optional-plugin', forceEnabled: false };
const serve = (plugin: Record<string, unknown>) => mocks.apiFetch.mockResolvedValue({
ok: true,
json: async () => ({ plugins: [plugin], themes: [] }),
});
const record = (overrides: Partial<InstalledPlugin> = {}) => syncedRecord({ id: 'optional-plugin', forceEnabled: false, ...overrides });
const synced = () => usePluginStore.getState().plugins.find((p) => p.id === 'optional-plugin');
it('turns a new one on, so it runs without anyone finding the switch', async () => {
serve(optional);
await usePluginStore.getState().initializePlugins();
expect(synced()).toMatchObject({ enabled: true, status: 'enabled', autoEnabled: true });
});
it('leaves a new one off when the admin chose "Off until users turn it on"', async () => {
serve({ ...optional, defaultOn: false });
await usePluginStore.getState().initializePlugins();
expect(synced()).toMatchObject({ enabled: false, status: 'installed' });
});
it('turns on one that was synced off before, while nobody chose', async () => {
usePluginStore.setState({ plugins: [record({ enabled: false, status: 'installed' })] });
vi.mocked(pluginStorage.codeIds).mockResolvedValue(['optional-plugin']);
serve(optional);
await usePluginStore.getState().initializePlugins();
expect(synced()).toMatchObject({ enabled: true });
});
it('keeps it off when the user turned it off', async () => {
usePluginStore.setState({ plugins: [record()] });
usePluginStore.getState().disablePlugin('optional-plugin');
vi.mocked(pluginStorage.codeIds).mockResolvedValue(['optional-plugin']);
serve(optional);
await usePluginStore.getState().initializePlugins();
expect(synced()).toMatchObject({ enabled: false, chosen: true });
});
it('keeps one off that was turned off because it kept failing', async () => {
usePluginStore.setState({ plugins: [record({ enabled: false, status: 'installed', chosen: true })] });
vi.mocked(pluginStorage.codeIds).mockResolvedValue(['optional-plugin']);
serve(optional);
await usePluginStore.getState().initializePlugins();
expect(synced()).toMatchObject({ enabled: false });
});
it('takes back what the default turned on when the admin switches to off, and nothing the user turned on', async () => {
vi.mocked(pluginStorage.codeIds).mockResolvedValue(['optional-plugin']);
serve({ ...optional, defaultOn: false });
usePluginStore.setState({ plugins: [record({ autoEnabled: true })] });
await usePluginStore.getState().initializePlugins();
expect(synced()).toMatchObject({ enabled: false });
expect(deactivatePlugin).toHaveBeenCalledWith('optional-plugin');
usePluginStore.setState({ plugins: [record({ chosen: true })], initialized: false });
await usePluginStore.getState().initializePlugins();
expect(synced()).toMatchObject({ enabled: true });
});
it('lets the next person at this browser start from the admin\'s default', async () => {
usePluginStore.setState({ plugins: [record({ enabled: false, status: 'disabled', chosen: true })] });
await purgeSignedOutData();
expect(synced()?.chosen).toBeUndefined();
});
});
it('re-downloads the bundle when the record is current but IndexedDB has no copy (#636)', async () => {
usePluginStore.setState({ plugins: [syncedRecord()] });
vi.mocked(pluginStorage.codeIds).mockResolvedValue([]);
+8 -2
View File
@@ -62,6 +62,12 @@ export interface ServerPlugin {
entrypoint: string;
enabled: boolean;
forceEnabled?: boolean;
/**
* Users get it off and turn it on themselves ("Off until users turn it
* on"). Unset is the default: on for every user, who may turn it off.
* `forceEnabled` wins over it.
*/
defaultOff?: boolean;
configSchema?: Record<string, PluginConfigField>;
settingsSchema?: Record<string, PluginSettingsField>;
/**
@@ -446,13 +452,13 @@ async function savePluginNow(
if (isSharedMode()) cacheBundle(bundleHash, code, signature);
}
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled' | 'defaultOff'>>): Promise<ServerPlugin | null> {
const updated = await withRegistryLock('plugins', () => updatePluginMetaNow(id, updates));
if (updated) await announceChange('plugins');
return updated;
}
async function updatePluginMetaNow(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
async function updatePluginMetaNow(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled' | 'defaultOff'>>): Promise<ServerPlugin | null> {
assertWritable('update plugin metadata');
const now = new Date().toISOString();
let updated: ServerPlugin | null = null;
+12
View File
@@ -254,6 +254,18 @@ export interface InstalledPlugin {
managed?: boolean;
// True when plugin is admin-enforced and cannot be disabled locally.
forceEnabled?: boolean;
/**
* For an admin's plugin: what users get while they haven't chosen.
* False when the admin picked "Off until users turn it on".
*/
defaultOn?: boolean;
/**
* The user turned it on or off themselves (or it was turned off because it
* kept failing), so the admin's default no longer changes it.
*/
chosen?: boolean;
/** Turned on by the admin's default, not by the user. */
autoEnabled?: boolean;
// True when plugin has been approved by an admin. Unapproved plugins cannot be enabled.
adminApproved?: boolean;
settingsSchema?: Record<string, SettingFieldSchema>;
+25 -9
View File
@@ -278,7 +278,7 @@ export const usePluginStore = create<PluginStoreState>()(
set(state => ({
plugins: state.plugins.map(p =>
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined, chosen: true, autoEnabled: false } : p
),
}));
@@ -300,7 +300,7 @@ export const usePluginStore = create<PluginStoreState>()(
set({
plugins: plugins.map(p =>
p.id === id ? { ...p, enabled: false, status: 'disabled' as PluginStatus, error: undefined } : p
p.id === id ? { ...p, enabled: false, status: 'disabled' as PluginStatus, error: undefined, chosen: true, autoEnabled: false } : p
),
});
},
@@ -504,7 +504,8 @@ function wireLoader(
plugins: state.plugins.map(p => (p.id === id
// enabled: false is persisted, so a reload doesn't start the
// failing plugin again behind the user's back.
? { ...p, enabled: false, status: 'error' as PluginStatus, error: 'auto-disabled' }
// chosen: the admin's default must not start it again either.
? { ...p, enabled: false, status: 'error' as PluginStatus, error: 'auto-disabled', chosen: true, autoEnabled: false }
: p)),
})),
});
@@ -524,6 +525,8 @@ interface ServerPluginInfo {
permissions: string[];
entrypoint: string;
forceEnabled: boolean;
/** On for users who haven't chosen; absent from older servers, which counts as on. */
defaultOn?: boolean;
/** Content hash of the bundle - changes whenever code changes, even if the version doesn't */
bundleHash?: string;
updatedAt?: string;
@@ -560,6 +563,7 @@ function serverMeta(sp: ServerPluginInfo) {
...(sp.tier ? { tier: sp.tier } : {}),
managed: true as const,
forceEnabled: sp.forceEnabled,
defaultOn: sp.defaultOn !== false,
bundleHash: sp.bundleHash,
httpOrigins: sp.httpOrigins,
apiPostPaths: sp.apiPostPaths,
@@ -702,11 +706,15 @@ async function syncServerPlugins(
const local = get().plugins.find(p => p.id === sp.id);
if (isNew || !local) {
// An admin's plugin is on for everyone unless the admin chose
// "Off until users turn it on".
const on = sp.forceEnabled || sp.defaultOn !== false;
const plugin: InstalledPlugin = {
id: sp.id,
type: sp.type as InstalledPlugin['type'],
enabled: sp.forceEnabled,
status: sp.forceEnabled ? 'enabled' : 'installed',
enabled: on,
status: on ? 'enabled' : 'installed',
...(on && !sp.forceEnabled ? { autoEnabled: true } : {}),
adminApproved: true, // Server-managed plugins are always approved
settings: {},
...serverMeta(sp),
@@ -720,12 +728,19 @@ async function syncServerPlugins(
}
// Force-enable in the same pass when the server flips it on, so the user
// doesn't need a second refresh for it to run.
const shouldAutoEnable = sp.forceEnabled && !local.enabled;
// doesn't need a second refresh for it to run. Until the user chooses,
// the admin's default decides: on, or off ("Off until users turn it
// on"), which only takes back what the default turned on.
const followsDefault = !local.chosen && !sp.forceEnabled;
const defaultOn = sp.defaultOn !== false;
const shouldAutoEnable = !local.enabled && (sp.forceEnabled || (followsDefault && defaultOn));
const shouldAutoDisable = local.enabled && followsDefault && !defaultOn && !!local.autoEnabled;
if (shouldAutoDisable) deactivatePlugin(sp.id);
const next: InstalledPlugin = {
...local,
...serverMeta(sp),
...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}),
...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const, ...(sp.forceEnabled ? {} : { autoEnabled: true }) } : {}),
...(shouldAutoDisable ? { enabled: false, status: 'installed' as const, autoEnabled: false } : {}),
};
if (needsBundle || JSON.stringify(next) !== JSON.stringify(local)) {
set(state => ({
@@ -913,7 +928,8 @@ onFullSignOut(() => {
usePluginStore.setState({
plugins: plugins.map((plugin) => {
if (plugin.managed) {
const { grantedPermissions: _kept, declinedPermissions: _declined, ...kept } = plugin;
// The next person here starts from the admin's default again.
const { grantedPermissions: _kept, declinedPermissions: _declined, chosen: _chosen, ...kept } = plugin;
return kept;
}
const { grantedPermissions: _granted, ...rest } = plugin;