feat: let users connect accounts for plugins in a dialog the plugin can't read, and disconnect them in settings

This commit is contained in:
Linus Rath
2026-10-14 04:14:13 +02:00
parent 3aec045163
commit 60257bb732
7 changed files with 470 additions and 1 deletions
+2
View File
@@ -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({
<ImpersonationReconciler />
{children}
<PluginDialogHost />
<PluginConnectDialog />
<QuickRuleHost />
<PluginConsentDialog />
<PluginOAuthCallbackListener />
@@ -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<string, unknown>) => (values ? `${key} ${JSON.stringify(values)}` : key);
return { useTranslations: () => t, useLocale: () => 'en' };
});
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
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<ConnectResult>;
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(<PluginConnectDialog />);
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(<PluginConnectDialog />);
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(<PluginConnectDialog />);
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();
});
});
@@ -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<string | null>(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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-0 z-[100001] flex items-center justify-center bg-black/50 p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) request.resolve({ connected: false });
}}
>
<div className="flex w-full max-w-[440px] flex-col rounded-lg border border-border bg-popover text-popover-foreground shadow-xl">
<form className="p-5" onSubmit={(e) => { e.preventDefault(); void submit(); }}>
<div className="mb-3 flex items-center gap-2">
<KeyRound className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
<h2 id={titleId} className="text-base font-semibold">{t('title', { label: spec.label })}</h2>
</div>
<p className="mb-4 text-sm text-muted-foreground [overflow-wrap:anywhere]">{intro}</p>
<div className="mb-4 flex flex-col gap-3">
{!fixedOrigin && (
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">{t('server')}</span>
{/* Not type="url": the browser would refuse "cloud.example.com"
without https://, which originFromInput adds. */}
<input
type="text"
inputMode="url"
autoCapitalize="off"
spellCheck={false}
value={server}
placeholder={t('server_hint')}
autoFocus
onChange={(e) => setServer(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</label>
)}
{needsUsername && (
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">{t('username')}</span>
<input
type="text"
value={username}
autoComplete="username"
autoFocus={!!fixedOrigin}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</label>
)}
{needsSecret && (
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">{t('secret')}</span>
<input
type="password"
value={secret}
autoComplete="off"
autoFocus={!!fixedOrigin && !needsUsername}
onChange={(e) => setSecret(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</label>
)}
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => request.resolve({ connected: false })}>{t('cancel')}</Button>
<Button type="submit" disabled={!canSubmit}>
{saving ? t('saving') : mode === 'signin' ? t('continue') : t('connect')}
</Button>
</div>
</form>
</div>
</div>
);
}
+48
View File
@@ -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<PluginStatus, string> = {
installed: 'bg-muted text-muted-foreground',
@@ -314,6 +315,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo
</div>
)}
{plugin.credentials && Object.values(plugin.credentials).some((c) => c.scope === 'user') && (
<ConnectedAccounts pluginId={plugin.id} />
)}
{!isManaged && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={onRemove} className="text-destructive hover:text-destructive">
@@ -328,6 +333,49 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo
);
}
// ─── Connected accounts ──────────────────────────────────────
/** Accounts the user connected for this plugin through the secret proxy. */
function ConnectedAccounts({ pluginId }: { pluginId: string }) {
const t = useTranslations('plugins.settings');
const [accounts, setAccounts] = useState<ConnectedAccount[] | null>(null);
useEffect(() => {
let live = true;
void listConnectedAccounts(pluginId).then((list) => { if (live) setAccounts(list); });
return () => { live = false; };
}, [pluginId]);
if (!accounts || accounts.length === 0) return null;
const disconnect = async (account: ConnectedAccount) => {
if (await forgetCredentials(pluginId, account.credentialId)) {
setAccounts((list) => (list ?? []).filter((a) => a.credentialId !== account.credentialId));
} else {
toast.error(t('disconnect_failed', { label: account.label }));
}
};
return (
<div className="space-y-1.5">
<span className="text-xs font-medium text-foreground">{t('accounts_heading')}</span>
{accounts.map((account) => {
const host = new URL(account.origin).host;
return (
<div key={account.credentialId} className="flex items-center justify-between gap-2 text-xs">
<span className="min-w-0 truncate text-muted-foreground">
{account.connected
? t('account_on', { label: account.username ? `${account.label} (${account.username})` : account.label, host })
: t('account_waiting', { label: account.label, host })}
</span>
<Button variant="ghost" size="sm" onClick={() => void disconnect(account)}>{t('disconnect')}</Button>
</div>
);
})}
</div>
);
}
// ─── Auto-generated Setting Field ────────────────────────────
interface PluginSettingFieldProps {
+148
View File
@@ -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<ConnectRequest, 'id' | 'resolve'>): Promise<ConnectResult> {
if (current) return Promise.reject(new ConnectBusyError());
return new Promise<ConnectResult>((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<string | null> {
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<boolean> {
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<ConnectedAccount[]> {
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;
}
}
+22 -1
View File
@@ -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": {
+4
View File
@@ -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[] = [];