mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: show a plugin's publisher and permissions before a quick install from the marketplace
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user