mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 && <> · {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) {
|
||||
|
||||
<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,
|
||||
};
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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'] };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user