mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user