From 60257bb7323b109ec75af8fa1c2120d3e4bbd78e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:14:13 +0200 Subject: [PATCH] feat: let users connect accounts for plugins in a dialog the plugin can't read, and disconnect them in settings --- app/(main)/[locale]/layout.tsx | 2 + .../__tests__/plugin-connect-dialog.test.tsx | 86 ++++++++++ components/plugins/plugin-connect-dialog.tsx | 160 ++++++++++++++++++ components/settings/plugins-settings.tsx | 48 ++++++ lib/plugin-sandbox/connect-dialog.ts | 148 ++++++++++++++++ locales/en/common.json | 23 ++- stores/plugin-store.ts | 4 + 7 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 components/plugins/__tests__/plugin-connect-dialog.test.tsx create mode 100644 components/plugins/plugin-connect-dialog.tsx create mode 100644 lib/plugin-sandbox/connect-dialog.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index e3bca2f0b..de115e8a8 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; +import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { PluginAppEvents } from "@/components/providers/plugin-app-events"; @@ -57,6 +58,7 @@ export default async function LocaleLayout({ {children} + diff --git a/components/plugins/__tests__/plugin-connect-dialog.test.tsx b/components/plugins/__tests__/plugin-connect-dialog.test.tsx new file mode 100644 index 000000000..86fa4894e --- /dev/null +++ b/components/plugins/__tests__/plugin-connect-dialog.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const apiFetch = vi.fn(); +vi.mock('next-intl', () => { + const t = (key: string, values?: Record) => (values ? `${key} ${JSON.stringify(values)}` : key); + return { useTranslations: () => t, useLocale: () => 'en' }; +}); +vi.mock('@/lib/browser-navigation', async (importOriginal) => ({ + ...(await importOriginal()), + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +import { PluginConnectDialog } from '../plugin-connect-dialog'; +import { originFromInput, requestConnect, type ConnectResult } from '@/lib/plugin-sandbox/connect-dialog'; + +beforeEach(() => { + apiFetch.mockReset(); + apiFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); +}); + +function open(mode: 'password' | 'signin' = 'password') { + let result: Promise; + act(() => { + result = requestConnect({ + pluginId: 'nextcloud', pluginName: 'Nextcloud', credentialId: 'cloud', mode, + spec: { label: 'Nextcloud', scope: 'user', inject: { type: 'basic' } }, + }); + }); + return result!; +} + +describe('connect dialog', () => { + it('sends the password to the server and tells the plugin only that it worked', async () => { + render(); + const result = open(); + + fireEvent.change(screen.getByText('server').parentElement!.querySelector('input')!, { target: { value: 'cloud.example.org/index.php' } }); + fireEvent.change(screen.getByText('username').parentElement!.querySelector('input')!, { target: { value: 'alice' } }); + fireEvent.change(screen.getByText('secret').parentElement!.querySelector('input')!, { target: { value: 'app-pw' } }); + expect(screen.getByText(/intro_password /)).toHaveTextContent('cloud.example.org'); + fireEvent.click(screen.getByRole('button', { name: 'connect' })); + + await expect(result).resolves.toEqual({ connected: true, origin: 'https://cloud.example.org' }); + const [url, init] = apiFetch.mock.calls[0]; + expect(url).toBe('/api/plugin-secrets'); + expect(JSON.parse(init.body)).toMatchObject({ + pluginId: 'nextcloud', credentialId: 'cloud', origin: 'https://cloud.example.org', username: 'alice', secret: 'app-pw', + }); + expect(JSON.stringify(await result)).not.toContain('app-pw'); + }); + + it('only approves the server for a sign-in flow', async () => { + render(); + const result = open('signin'); + + expect(screen.queryByText('secret')).toBeNull(); + fireEvent.change(screen.getByText('server').parentElement!.querySelector('input')!, { target: { value: 'https://cloud.example.org' } }); + fireEvent.click(screen.getByRole('button', { name: 'continue' })); + + await expect(result).resolves.toEqual({ connected: false, origin: 'https://cloud.example.org' }); + expect(JSON.parse(apiFetch.mock.calls[0][1].body)).not.toHaveProperty('secret'); + }); + + it('shows the server error and stays open', async () => { + apiFetch.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'The server address must start with https://' }), { status: 400 })); + render(); + const result = open(); + fireEvent.change(screen.getByText('server').parentElement!.querySelector('input')!, { target: { value: 'cloud.example.org' } }); + fireEvent.change(screen.getByText('username').parentElement!.querySelector('input')!, { target: { value: 'alice' } }); + fireEvent.change(screen.getByText('secret').parentElement!.querySelector('input')!, { target: { value: 'pw' } }); + fireEvent.click(screen.getByRole('button', { name: 'connect' })); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('must start with https')); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + await expect(result).resolves.toEqual({ connected: false }); + }); +}); + +describe('originFromInput', () => { + it('adds https and keeps only the origin', () => { + expect(originFromInput('cloud.example.org/nextcloud')).toBe('https://cloud.example.org'); + expect(originFromInput('https://u:p@cloud.example.org')).toBeNull(); + expect(originFromInput('javascript:alert(1)')).toBeNull(); + }); +}); diff --git a/components/plugins/plugin-connect-dialog.tsx b/components/plugins/plugin-connect-dialog.tsx new file mode 100644 index 000000000..962e70c54 --- /dev/null +++ b/components/plugins/plugin-connect-dialog.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useEffect, useId, useState, useSyncExternalStore } from 'react'; +import { useTranslations } from 'next-intl'; +import { KeyRound } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + currentConnect, + originFromInput, + saveCredential, + subscribeConnect, +} from '@/lib/plugin-sandbox/connect-dialog'; + +/** + * The host's own "Connect account" dialog (see lib/plugin-sandbox/connect-dialog.ts). + * Everything here is rendered by the host: the plugin can't change the text, + * and what the user types goes to the server, never to the plugin. + */ +export function PluginConnectDialog() { + const request = useSyncExternalStore(subscribeConnect, currentConnect, () => null); + const t = useTranslations('plugins.connect'); + const titleId = useId(); + const [server, setServer] = useState(''); + const [username, setUsername] = useState(''); + const [secret, setSecret] = useState(''); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setServer(request?.suggestedOrigin ?? ''); + setUsername(request?.suggestedUsername ?? ''); + setSecret(''); + setError(null); + setSaving(false); + }, [request?.id, request?.suggestedOrigin, request?.suggestedUsername]); + + useEffect(() => { + if (!request) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') request.resolve({ connected: false }); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [request]); + + if (!request) return null; + + const { spec, mode, pluginName } = request; + const fixedOrigin = spec.origin; + const needsUsername = mode === 'password' && spec.inject.type === 'basic'; + const needsSecret = mode === 'password' && spec.inject.type !== 'none'; + const origin = fixedOrigin ?? originFromInput(server); + const canSubmit = !!origin && (!needsSecret || !!secret) && (!needsUsername || !!username.trim()) && !saving; + + const submit = async () => { + if (!origin) { + setError(t('error_server')); + return; + } + setSaving(true); + setError(null); + const failure = await saveCredential({ + pluginId: request.pluginId, + credentialId: request.credentialId, + label: spec.label, + origin, + inject: spec.inject, + ...(needsUsername ? { username: username.trim() } : {}), + ...(needsSecret ? { secret } : {}), + }); + setSaving(false); + if (failure) { + setError(t('error_save', { error: failure })); + return; + } + request.resolve({ connected: mode === 'password', origin }); + }; + + const host = origin ? new URL(origin).host : null; + const intro = mode === 'signin' + ? t('intro_signin', { name: pluginName, label: spec.label }) + : host + ? t('intro_password', { name: pluginName, label: spec.label, host }) + : t('intro_password_any', { name: pluginName, label: spec.label }); + + return ( +
{ + if (e.target === e.currentTarget) request.resolve({ connected: false }); + }} + > +
+
{ e.preventDefault(); void submit(); }}> +
+ +

{t('title', { label: spec.label })}

+
+

{intro}

+
+ {!fixedOrigin && ( + + )} + {needsUsername && ( + + )} + {needsSecret && ( + + )} + {error &&

{error}

} +
+
+ + +
+
+
+
+ ); +} diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index f58bb1b44..6f5a49b55 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -14,6 +14,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { PluginPermissionList } from '@/components/plugins/plugin-permission-list'; import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; import { effectiveSettings } from '@/lib/plugin-settings'; +import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog'; const STATUS_COLORS: Record = { installed: 'bg-muted text-muted-foreground', @@ -314,6 +315,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo )} + {plugin.credentials && Object.values(plugin.credentials).some((c) => c.scope === 'user') && ( + + )} + {!isManaged && (
+
+ ); + })} + + ); +} + // ─── Auto-generated Setting Field ──────────────────────────── interface PluginSettingFieldProps { diff --git a/lib/plugin-sandbox/connect-dialog.ts b/lib/plugin-sandbox/connect-dialog.ts new file mode 100644 index 000000000..18c86d184 --- /dev/null +++ b/lib/plugin-sandbox/connect-dialog.ts @@ -0,0 +1,148 @@ +// Host-side "Connect account" requests. A plugin asks to connect one of the +// user-scope credentials its manifest declares (api.proxy.connect); the host +// shows its own dialog naming the plugin and the server, the user types the +// password there, and it goes straight to /api/plugin-secrets. The plugin +// only learns whether the account is connected. + +import type { UserCredentialSpec } from '@/lib/plugin-secrets/credentials'; +import { apiFetch } from '../browser-navigation'; + +export interface ConnectRequest { + id: string; + pluginId: string; + pluginName: string; + credentialId: string; + spec: UserCredentialSpec; + /** + * `password`: the user types a password or key here. + * `signin`: only the server is approved here; the plugin then runs a + * sign-in flow whose answer the proxy captures (Nextcloud Login Flow v2). + */ + mode: 'password' | 'signin'; + /** Server address to suggest when the manifest leaves it to the user. */ + suggestedOrigin?: string; + suggestedUsername?: string; + resolve: (result: ConnectResult) => void; +} + +export type ConnectResult = { connected: boolean; origin?: string }; + +let current: ConnectRequest | null = null; +const listeners = new Set<() => void>(); + +function notify(): void { + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function subscribeConnect(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function currentConnect(): ConnectRequest | null { + return current; +} + +export class ConnectBusyError extends Error { + constructor() { + super('Another account connection is already open'); + } +} + +/** Ask the user to connect an account. One request at a time, app-wide. */ +export function requestConnect(input: Omit): Promise { + if (current) return Promise.reject(new ConnectBusyError()); + return new Promise((resolve) => { + current = { + ...input, + id: Math.random().toString(36).slice(2), + resolve: (result) => { + current = null; + notify(); + resolve(result); + }, + }; + notify(); + }); +} + +/** Close an open request of this plugin (it was turned off or removed). */ +export function cancelConnectFor(pluginId: string): void { + if (current?.pluginId === pluginId) current.resolve({ connected: false }); +} + +export interface SaveCredentialInput { + pluginId: string; + credentialId: string; + label: string; + origin: string; + inject: UserCredentialSpec['inject']; + username?: string; + secret?: string; +} + +/** POST to /api/plugin-secrets. Returns an error message, or null. */ +export async function saveCredential(input: SaveCredentialInput): Promise { + const res = await apiFetch('/api/plugin-secrets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (res.ok) return null; + try { + const body = await res.json() as { error?: string }; + return body.error || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +/** Remove a plugin's stored credentials (all of them without credentialId). */ +export async function forgetCredentials(pluginId: string, credentialId?: string): Promise { + const query = new URLSearchParams({ plugin: pluginId, ...(credentialId ? { credential: credentialId } : {}) }); + try { + const res = await apiFetch(`/api/plugin-secrets?${query}`, { method: 'DELETE' }); + return res.ok; + } catch { + return false; + } +} + +export interface ConnectedAccount { + pluginId: string; + credentialId: string; + label: string; + origin: string; + username?: string; + connected: boolean; + updatedAt: string; +} + +export async function listConnectedAccounts(pluginId?: string): Promise { + const query = pluginId ? `?plugin=${encodeURIComponent(pluginId)}` : ''; + try { + const res = await apiFetch(`/api/plugin-secrets${query}`); + if (!res.ok) return []; + const body = await res.json() as { credentials?: ConnectedAccount[] }; + return Array.isArray(body.credentials) ? body.credentials : []; + } catch { + return []; + } +} + +/** Normalise what the user typed into an origin, adding https:// if missing. */ +export function originFromInput(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const url = new URL(withScheme); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + if (url.username || url.password) return null; + return url.origin; + } catch { + return null; + } +} diff --git a/locales/en/common.json b/locales/en/common.json index 942d372a4..736fec31e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4019,7 +4019,12 @@ "error_approval_unavailable": "This plugin was installed before approvals were required. Remove it and install it again to send it for approval.", "error_approval_denied": "Your administrator did not approve this plugin.", "error_approval_pending": "Waiting for your administrator to approve this plugin.", - "error_auto_disabled": "Turned off because it kept failing. Turn it on again to retry." + "error_auto_disabled": "Turned off because it kept failing. Turn it on again to retry.", + "accounts_heading": "Connected accounts", + "account_on": "{label} on {host}", + "account_waiting": "{label} on {host} (sign-in not finished)", + "disconnect": "Disconnect", + "disconnect_failed": "Could not disconnect {label}" }, "consent": { "title": "Allow {name} to do this?", @@ -4255,6 +4260,22 @@ "turn_off": "Turn off {name}", "password_note": "This form comes from the plugin {name}, not from the webmail. Only enter a password if you trust this plugin.", "toast_auto_disabled": "{name} was turned off because it kept failing." + }, + "connect": { + "title": "Connect {label}", + "intro_password": "{name} wants to use your {label} account. Bulwark keeps the password on its server and only sends it to {host}. The plugin never sees it.", + "intro_password_any": "{name} wants to use your {label} account. Bulwark keeps the password on its server and only sends it to the server you enter here. The plugin never sees it.", + "intro_signin": "{name} wants to sign in to your {label} account. Enter the server here, then sign in on its own page. Bulwark keeps the access it gets on its server; the plugin never sees it.", + "server": "Server address", + "server_hint": "cloud.example.com", + "username": "User name", + "secret": "Password or key", + "connect": "Connect", + "continue": "Continue", + "cancel": "Cancel", + "saving": "Saving…", + "error_server": "Enter the server's address, for example https://cloud.example.com", + "error_save": "Could not save: {error}" } }, "global_search": { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index d07463f90..94f47c135 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -1,6 +1,7 @@ // Plugin store - manages installed plugins and lifecycle. Slot registrations // are owned by `lib/plugin-sandbox/registry` (per-iframe), not by the store. +import { cancelConnectFor, forgetCredentials } from '@/lib/plugin-sandbox/connect-dialog'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types'; @@ -346,6 +347,9 @@ function removePluginData(id: string): void { removeAllPluginHooks(id); void pluginStorage.deleteCode(id).catch(() => {}); void pluginStorage.deletePreview(id).catch(() => {}); + // Accounts the user connected for it live on the server (this mail account's). + cancelConnectFor(id); + void forgetCredentials(id); if (typeof window !== 'undefined') { const prefix = `plugin:${id}:`; const keysToRemove: string[] = [];