mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins whether the admin has set up their accounts and keys
This commit is contained in:
@@ -29,7 +29,11 @@ vi.mock('@/lib/plugin-secrets/vault', () => ({
|
||||
const proxied: unknown[] = [];
|
||||
vi.mock('@/lib/plugin-secrets/proxy', async (orig) => {
|
||||
const real = await orig<typeof import('@/lib/plugin-secrets/proxy')>();
|
||||
return { ...real, proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }) };
|
||||
return {
|
||||
...real,
|
||||
proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }),
|
||||
adminCredentialStatus: vi.fn(async () => [{ credentialId: 'shared', configured: true }]),
|
||||
};
|
||||
});
|
||||
|
||||
import { POST as saveCredential } from '../route';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials';
|
||||
import { adminCredentialStatus } from '@/lib/plugin-secrets/proxy';
|
||||
import {
|
||||
VaultFullError,
|
||||
deleteUserCredentials,
|
||||
@@ -33,7 +34,10 @@ export async function GET(request: NextRequest) {
|
||||
if (pluginId !== undefined && !PLUGIN_ID_RE.test(pluginId)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ credentials: await listUserCredentials(user, pluginId) }, { headers: NO_STORE });
|
||||
const credentials = await listUserCredentials(user, pluginId);
|
||||
// For one plugin, also say which of its admin credentials are set up.
|
||||
const admin = pluginId ? await adminCredentialStatus(pluginId) : [];
|
||||
return NextResponse.json({ credentials, admin }, { headers: NO_STORE });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,7 @@ import { checkCredentials, authHeaderFor } from '../plugin-secrets/credentials';
|
||||
import {
|
||||
deleteUserCredentials, getUserCredential, listUserCredentials, putUserCredential,
|
||||
} from '../plugin-secrets/vault';
|
||||
import { proxyRequest, ProxyError, PROXY_RATE_LIMIT, resetProxyLimits } from '../plugin-secrets/proxy';
|
||||
import { adminCredentialStatus, proxyRequest, ProxyError, PROXY_RATE_LIMIT, resetProxyLimits } from '../plugin-secrets/proxy';
|
||||
|
||||
const USER = { username: 'alice@example.org', serverUrl: 'https://mail.example.org' };
|
||||
const OTHER = { username: 'bob@example.org', serverUrl: 'https://mail.example.org' };
|
||||
@@ -257,6 +257,28 @@ describe('proxy', () => {
|
||||
expect(seen[0].headers.authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('says which admin credentials are set up, without their secrets', async () => {
|
||||
const plugin = {
|
||||
id: 'ai', enabled: true,
|
||||
credentials: {
|
||||
llm: { label: 'LLM', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'key', originKey: 'url' },
|
||||
other: { label: 'Other', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'otherKey', origin: 'https://o.example' },
|
||||
own: { label: 'Own', scope: 'user', inject: { type: 'bearer' } },
|
||||
},
|
||||
};
|
||||
vi.mocked(getPlugin).mockResolvedValue(plugin as never);
|
||||
vi.mocked(getPluginConfig).mockResolvedValue({ key: 'sk-admin', url: `${origin}/v1` });
|
||||
try {
|
||||
const status = await adminCredentialStatus('ai');
|
||||
|
||||
expect(status).toEqual([{ credentialId: 'llm', configured: true }, { credentialId: 'other', configured: false }]);
|
||||
expect(JSON.stringify(status)).not.toContain('sk-admin');
|
||||
} finally {
|
||||
vi.mocked(getPlugin).mockResolvedValue(null);
|
||||
vi.mocked(getPluginConfig).mockResolvedValue({});
|
||||
}
|
||||
});
|
||||
|
||||
it('uses an admin credential for an installed plugin', async () => {
|
||||
vi.mocked(getPlugin).mockResolvedValueOnce({
|
||||
id: 'ai', enabled: true,
|
||||
|
||||
@@ -121,14 +121,22 @@ export interface ConnectedAccount {
|
||||
}
|
||||
|
||||
export async function listConnectedAccounts(pluginId?: string): Promise<ConnectedAccount[]> {
|
||||
return (await credentialOverview(pluginId)).credentials;
|
||||
}
|
||||
|
||||
/** The user's connected accounts, and (for one plugin) which admin credentials are set up. */
|
||||
export async function credentialOverview(pluginId?: string): Promise<{ credentials: ConnectedAccount[]; admin: { credentialId: string; configured: boolean }[] }> {
|
||||
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 : [];
|
||||
if (!res.ok) return { credentials: [], admin: [] };
|
||||
const body = await res.json() as { credentials?: ConnectedAccount[]; admin?: { credentialId: string; configured: boolean }[] };
|
||||
return {
|
||||
credentials: Array.isArray(body.credentials) ? body.credentials : [],
|
||||
admin: Array.isArray(body.admin) ? body.admin : [],
|
||||
};
|
||||
} catch {
|
||||
return [];
|
||||
return { credentials: [], admin: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { apiFetch, withBasePath } from '../browser-navigation';
|
||||
import { requestConnect, forgetCredentials, listConnectedAccounts } from './connect-dialog';
|
||||
import { requestConnect, forgetCredentials, credentialOverview } from './connect-dialog';
|
||||
|
||||
/** Where streamed chunks and upload progress go: the iframe that asked. */
|
||||
export interface StreamSink {
|
||||
@@ -281,10 +281,17 @@ export async function proxyConnect(plugin: InstalledPlugin, args: unknown[]): Pr
|
||||
});
|
||||
}
|
||||
|
||||
/** `api.proxy.accounts()`: which of its credentials are connected, without secrets. */
|
||||
export async function proxyAccounts(plugin: InstalledPlugin): Promise<{ credentialId: string; origin: string; username?: string; connected: boolean }[]> {
|
||||
const list = await listConnectedAccounts(plugin.id);
|
||||
return list.map(({ credentialId, origin, username, connected }) => ({ credentialId, origin, ...(username ? { username } : {}), connected }));
|
||||
/**
|
||||
* `api.proxy.accounts()`: the user's connected accounts (with server and
|
||||
* user name) and, with `scope: 'admin'`, whether each admin credential is
|
||||
* set up. No secrets, and no server address for admin credentials.
|
||||
*/
|
||||
export async function proxyAccounts(plugin: InstalledPlugin): Promise<{ credentialId: string; scope: 'user' | 'admin'; origin?: string; username?: string; connected: boolean }[]> {
|
||||
const { credentials, admin } = await credentialOverview(plugin.id);
|
||||
return [
|
||||
...credentials.map(({ credentialId, origin, username, connected }) => ({ credentialId, scope: 'user' as const, origin, ...(username ? { username } : {}), connected })),
|
||||
...admin.map(({ credentialId, configured }) => ({ credentialId, scope: 'admin' as const, connected: configured })),
|
||||
];
|
||||
}
|
||||
|
||||
/** `api.proxy.disconnect(credentialId)`. */
|
||||
|
||||
@@ -520,8 +520,12 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
/** Show the host's dialog to connect a user credential. */
|
||||
connect: (credentialId: string, opts?: { mode?: 'password' | 'signin'; server?: string; username?: string }) =>
|
||||
callApi('proxy.connect', [credentialId, opts ?? {}], 0) as Promise<{ connected: boolean; origin?: string }>,
|
||||
/** Which credentials are connected (no secrets). */
|
||||
accounts: () => callApi('proxy.accounts', []) as Promise<Array<{ credentialId: string; origin: string; username?: string; connected: boolean }>>,
|
||||
/**
|
||||
* Which credentials are ready: accounts the user connected (scope
|
||||
* 'user', with server and user name) and the admin's (scope 'admin',
|
||||
* connected = set up). No secrets.
|
||||
*/
|
||||
accounts: () => callApi('proxy.accounts', []) as Promise<Array<{ credentialId: string; scope: 'user' | 'admin'; origin?: string; username?: string; connected: boolean }>>,
|
||||
disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise<boolean>,
|
||||
},
|
||||
// Reading mail (permission: email:read) and keyword mutations
|
||||
|
||||
@@ -92,6 +92,28 @@ export async function resolveCredential(user: VaultUser, pluginId: string, crede
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of a plugin's admin credentials are ready to use (secret and server
|
||||
* set). No secret or address leaves the server; plugins learn only yes/no.
|
||||
*/
|
||||
export async function adminCredentialStatus(pluginId: string): Promise<{ credentialId: string; configured: boolean }[]> {
|
||||
const plugin = await getPlugin(pluginId);
|
||||
const specs = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials;
|
||||
if (!plugin || !plugin.enabled || !specs) return [];
|
||||
const adminIds = Object.entries(specs).filter(([, spec]) => spec.scope === 'admin').map(([id]) => id);
|
||||
if (adminIds.length === 0) return [];
|
||||
const user = { username: '', serverUrl: '' };
|
||||
return Promise.all(adminIds.map(async (credentialId) => ({
|
||||
credentialId,
|
||||
configured: !!(await resolveAdminOnly(user, pluginId, credentialId)),
|
||||
})));
|
||||
}
|
||||
|
||||
async function resolveAdminOnly(user: VaultUser, pluginId: string, credentialId: string): Promise<ResolvedCredential | null> {
|
||||
const resolved = await resolveCredential(user, pluginId, credentialId);
|
||||
return resolved?.scope === 'admin' ? resolved : null;
|
||||
}
|
||||
|
||||
// ─── Rate limiting ────────────────────────────────────────────
|
||||
|
||||
const windows = new Map<string, number[]>();
|
||||
|
||||
Reference in New Issue
Block a user