diff --git a/app/(main)/admin/__tests__/marketplace-quick-install.test.tsx b/app/(main)/admin/__tests__/marketplace-quick-install.test.tsx new file mode 100644 index 000000000..b2cdc9d4f --- /dev/null +++ b/app/(main)/admin/__tests__/marketplace-quick-install.test.tsx @@ -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 }> = []; +let policy: Record = { 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(); + 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(); + 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(); + 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(); + 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(); + 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 } }), + }))); + }); +}); diff --git a/app/(main)/admin/_pages/marketplace-detail.tsx b/app/(main)/admin/_pages/marketplace-detail.tsx index a55a8ffcb..3707537c0 100644 --- a/app/(main)/admin/_pages/marketplace-detail.tsx +++ b/app/(main)/admin/_pages/marketplace-detail.tsx @@ -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={( - {isPlugin ? (ext.pluginType || 'plugin') : 'theme'} + {isPlugin ? pluginTypeLabel(ext.pluginType) : 'Theme'} {ext.author && ( by {ext.author.displayName} diff --git a/app/(main)/admin/_pages/marketplace.tsx b/app/(main)/admin/_pages/marketplace.tsx index d0cd014ee..9672d9966 100644 --- a/app/(main)/admin/_pages/marketplace.tsx +++ b/app/(main)/admin/_pages/marketplace.tsx @@ -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(null); const [updates, setUpdates] = useState(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(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() { /> + 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 ? ( +
+ +
+ ) : null} + confirmText="Install" + cancelText="Cancel" + />
{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); + }} /> ))}
@@ -716,7 +744,7 @@ function ExtensionCard({
- {isPlugin ? (extension.pluginType || 'plugin') : 'theme'} + {isPlugin ? pluginTypeLabel(extension.pluginType) : 'Theme'} {offered?.channel === 'beta' && beta} {extension.deprecated && deprecated}