feat: show a plugin's publisher and permissions before a quick install from the marketplace

This commit is contained in:
Linus Rath
2026-11-04 08:10:18 +01:00
parent 93f573f750
commit 5a7f22b7cf
3 changed files with 136 additions and 5 deletions
@@ -0,0 +1,100 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Usability 2026-11-04, settings-21: Quick install of a plugin says what it
// may do in every mailbox before anything is installed; themes stay one click.
const calls: Array<{ url: string; method: string; body: Record<string, unknown> }> = [];
let policy: Record<string, unknown> = { features: { pluginsEnabled: true } };
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET';
const raw = init?.body;
calls.push({ url, method, body: typeof raw === 'string' ? JSON.parse(raw) : {} });
const path = url.split('?')[0];
const data = method !== 'GET' ? { warnings: [] }
: path === '/api/admin/marketplace' ? { data: LISTINGS, meta: { page: 1, perPage: 12, total: 2 }, channel: 'stable', directoryUrl: 'https://dir.example' }
: path === '/api/admin/policy' ? policy
: path === '/api/admin/marketplace/updates' ? { installed: [], updates: [], yanked: [], deprecated: [], suspended: [], advisories: [], unknown: [], directoryUrl: '', channel: 'stable', error: null }
: {};
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('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
const base = {
description: 'd', totalDownloads: 3, rating: { average: 0, count: 0 }, tags: [], featured: false, deprecated: false,
bannerUrl: null, iconUrl: null, installed: false, installedVersion: null, latestVersion: '1.0.0',
latest: { version: '1.0.0', channel: 'stable' },
};
const LISTINGS = [
{ ...base, slug: 'send-checks', name: 'Send Checks', type: 'plugin', pluginType: 'hook', permissions: ['email:send', 'email:read'], author: { displayName: 'Bulwark Team' } },
{ ...base, slug: 'bolt', name: 'Bolt', type: 'theme', pluginType: null, permissions: [], author: { displayName: 'Someone' } },
];
import { MarketplacePage } from '../_pages/marketplace';
const installs = () => calls.filter((c) => c.url === '/api/admin/marketplace' && c.method === 'POST');
beforeEach(() => {
calls.length = 0;
policy = { features: { pluginsEnabled: true } };
});
describe('Quick install', () => {
it('shows a plugin\'s publisher and permissions before installing it', async () => {
render(<MarketplacePage />);
const card = (await screen.findByText('Send Checks')).closest('.group') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: /Quick install/ }));
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('Install Send Checks?')).toBeTruthy();
expect(within(dialog).getByText(/By Bulwark Team\./)).toBeTruthy();
// The next-intl mock returns keys: the user-facing permission titles.
expect(within(dialog).getByText('email_send.title')).toBeTruthy();
expect(within(dialog).getByText('email_read.title')).toBeTruthy();
expect(installs()).toEqual([]);
fireEvent.click(within(dialog).getByRole('button', { name: 'Install' }));
await waitFor(() => expect(installs()).toEqual([expect.objectContaining({ body: { slug: 'send-checks', version: '1.0.0', type: 'plugin' } })]));
});
it('installs nothing on Cancel', async () => {
render(<MarketplacePage />);
const card = (await screen.findByText('Send Checks')).closest('.group') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: /Quick install/ }));
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(installs()).toEqual([]);
});
it('installs a theme with one click', async () => {
render(<MarketplacePage />);
const card = (await screen.findByText('Bolt')).closest('.group') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: /Quick install/ }));
await waitFor(() => expect(installs()).toHaveLength(1));
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('names the plugin type in words', async () => {
render(<MarketplacePage />);
const card = (await screen.findByText('Send Checks')).closest('.group') as HTMLElement;
expect(within(card).getByText('Works in the background')).toBeTruthy();
expect(within(card).queryByText('hook')).toBeNull();
});
it('asks to turn plugins on for users when they are off', async () => {
policy = { features: { pluginsEnabled: false } };
render(<MarketplacePage />);
const card = (await screen.findByText('Send Checks')).closest('.group') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: /Quick install/ }));
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Install' }));
const question = await screen.findByText('Turn on plugins for users too?');
fireEvent.click(within(question.closest('[role="alertdialog"]') as HTMLElement).getByRole('button', { name: 'Turn on' }));
await waitFor(() => expect(calls).toContainEqual(expect.objectContaining({
url: '/api/admin/policy', method: 'PUT', body: expect.objectContaining({ features: { pluginsEnabled: true } }),
})));
});
});
@@ -39,6 +39,8 @@ import { Badge } from '@/components/admin/badge';
import { ScreenshotGallery } from '@/components/admin/screenshot-gallery';
import { useAdminFeedback } from '@/components/admin/feedback';
import { installVersion } from './marketplace';
import { offerToTurnOnPlugins, turnOnNote, type TurnOnOutcome } from './turn-on-plugins';
import { pluginTypeLabel } from './plugin-type-label';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { scrollBehavior } from '@/lib/reduced-motion';
@@ -207,11 +209,12 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
}
const warnings = result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : '';
const signed = result.signature === 'verified' ? ', signed by the directory' : '';
const outcome: TurnOnOutcome = !isUpdate && data.extension.type === 'plugin' ? await offerToTurnOnPlugins(confirm) : 'already-on';
setMessage({
type: 'success',
text: isUpdate
? `"${data.extension.name}" is now v${version}${signed}${warnings}`
: `"${data.extension.name}" installed (v${version})${signed}${warnings}`,
: `"${data.extension.name}" installed (v${version})${signed}${warnings}.${turnOnNote(outcome)}`,
});
fetchPreview(selected ?? null);
}
@@ -384,7 +387,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
)}
description={(
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Badge tone={isPlugin ? 'info' : 'accent'}>{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}</Badge>
<Badge tone={isPlugin ? 'info' : 'accent'}>{isPlugin ? pluginTypeLabel(ext.pluginType) : 'Theme'}</Badge>
{ext.author && (
<span className="inline-flex items-center gap-1">
by {ext.author.displayName}
+31 -3
View File
@@ -23,6 +23,9 @@ import { ConfigNote, inputClass } from '@/components/admin/config-items';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { ChangelogBadges, ChangelogEntries, anyFlag, type ChangelogEntry, type ChangelogFlags } from '@/components/admin/changelog';
import { PluginPermissionList } from '@/components/plugins/plugin-permission-list';
import { offerToTurnOnPlugins, turnOnNote, type TurnOnOutcome } from './turn-on-plugins';
import { pluginTypeLabel } from './plugin-type-label';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -171,6 +174,8 @@ export function MarketplacePage() {
const [error, setError] = useState<string | null>(null);
const [updates, setUpdates] = useState<UpdatesResult | null>(null);
const [updatesLoading, setUpdatesLoading] = useState(true);
// A plugin's quick install asks first, with what it may do (themes don't).
const [quickInstall, setQuickInstall] = useState<MarketplaceListing | null>(null);
const fetchExtensions = useCallback(async () => {
setLoading(true);
@@ -244,11 +249,12 @@ export function MarketplacePage() {
}
const warnings = result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : '';
const signed = result.signature === 'verified' ? ', signed by the directory' : '';
const outcome: TurnOnOutcome = !isUpdate && ext.type === 'plugin' ? await offerToTurnOnPlugins(confirm) : 'already-on';
setMessage({
type: 'success',
text: isUpdate
? `"${ext.name}" updated to v${version}${signed}${warnings}`
: `"${ext.name}" installed (v${version})${signed}${warnings}`,
: `"${ext.name}" installed (v${version})${signed}${warnings}.${turnOnNote(outcome)}`,
});
setExtensions((prev) => prev.map((e) => (e.slug === ext.slug
? { ...e, installed: true, installedVersion: version, installedSource: { kind: 'marketplace', slug: ext.slug, directoryUrl: directoryUrl ?? '' } }
@@ -299,6 +305,24 @@ export function MarketplacePage() {
/>
<ConfirmDialog {...confirmProps} />
<ConfirmDialog
isOpen={quickInstall !== null}
onClose={() => setQuickInstall(null)}
onConfirm={() => {
const ext = quickInstall;
setQuickInstall(null);
if (ext?.latestVersion) void install(ext, ext.latestVersion, false);
}}
title={`Install ${quickInstall?.name ?? ''}?`}
message={`${quickInstall?.author ? `By ${quickInstall.author.displayName}. ` : ''}It is installed for every user and turned on for them; they can turn it off in their settings. What it may do in each mailbox:`}
details={quickInstall ? (
<div className="mt-3 max-h-64 overflow-y-auto" data-testid="quick-install-permissions">
<PluginPermissionList permissions={quickInstall.permissions ?? []} compact />
</div>
) : null}
confirmText="Install"
cancelText="Cancel"
/>
<div className="space-y-10">
{updates && noticeCount > 0 && (
@@ -404,7 +428,11 @@ export function MarketplacePage() {
key={ext.slug}
extension={ext}
installing={installing.has(ext.slug)}
onInstall={() => ext.latestVersion && install(ext, ext.latestVersion, ext.installed)}
onInstall={() => {
if (!ext.latestVersion) return;
if (ext.type === 'plugin' && !ext.installed) setQuickInstall(ext);
else void install(ext, ext.latestVersion, ext.installed);
}}
/>
))}
</div>
@@ -716,7 +744,7 @@ function ExtensionCard({
</div>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<Badge tone={isPlugin ? 'info' : 'accent'}>
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
{isPlugin ? pluginTypeLabel(extension.pluginType) : 'Theme'}
</Badge>
{offered?.channel === 'beta' && <Badge tone="info">beta</Badge>}
{extension.deprecated && <Badge>deprecated</Badge>}