refactor(plugins): share the plugin user settings, OAuth and activity logic between desktop and phone

The declared field form with its save, the actions and their confirm state, the OAuth connect and disconnect and the activity log now live in usePluginUserSettings, usePluginOAuth and usePluginActivity. Each shell keeps its own form, confirm dialog or sheet and picker.
This commit is contained in:
Maurice
2026-10-09 10:22:40 +02:00
parent 2acfc9cb43
commit 5ee7cc052c
7 changed files with 500 additions and 249 deletions
@@ -0,0 +1,169 @@
// FE-COMP-PLUGIN-USERSET-001 to -012: the per plugin user settings logic both settings shells share.
import { act, renderHook, waitFor } from '@testing-library/react';
import { pluginsApi, type PluginAction, type PluginUserSettingField } from '../../api/client';
import { SECRET_MASK } from './settingsForm';
import { usePluginOAuth, usePluginUserSettings } from './usePluginUserSettings';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({
useTranslation: () => ({ t: (k: string, p?: Record<string, unknown>) => (p ? `${k}:${String(p.field)}` : k) }),
}));
const FIELDS = [
{ key: 'token', label: 'Token', input_type: 'text', required: true, secret: true },
{ key: 'units', label: '', input_type: 'text', required: true, secret: false, default: 'km' },
{ key: 'on', label: 'On', input_type: 'checkbox', required: false, secret: false },
] as unknown as PluginUserSettingField[];
const PING = { key: 'ping', label: 'Ping', danger: false } as unknown as PluginAction;
const WIPE = { key: 'wipe', label: 'Wipe', danger: true } as unknown as PluginAction;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(pluginsApi, 'userSettings').mockResolvedValue({
fields: FIELDS,
config: { token: SECRET_MASK, on: true },
actions: [PING, WIPE],
});
vi.spyOn(pluginsApi, 'oauthStatus').mockResolvedValue({ configured: false, connected: false });
vi.spyOn(pluginsApi, 'saveUserSettings').mockResolvedValue({
config: { token: SECRET_MASK, units: 'mi', on: false },
});
vi.spyOn(pluginsApi, 'runAction').mockResolvedValue({ ok: true, message: 'pong' });
});
afterEach(() => vi.restoreAllMocks());
async function loaded() {
const hook = renderHook(() => usePluginUserSettings('p'));
await waitFor(() => expect(hook.result.current.fields).not.toBeNull());
return hook;
}
describe('usePluginUserSettings', () => {
it('FE-COMP-PLUGIN-USERSET-001: seeds the draft from the stored config and the manifest defaults', async () => {
const { result } = await loaded();
expect(pluginsApi.userSettings).toHaveBeenCalledWith('p');
expect(result.current.values).toEqual({ token: SECRET_MASK, units: 'km', on: true });
expect(result.current.actions).toEqual([PING, WIPE]);
expect(result.current.hasFields).toBe(true);
expect(result.current.visible).toBe(true);
});
it('FE-COMP-PLUGIN-USERSET-002: stays hidden while loading and when there is nothing to offer', async () => {
vi.mocked(pluginsApi.userSettings).mockResolvedValue({ fields: [], config: {}, actions: [] });
const hook = renderHook(() => usePluginUserSettings('p'));
expect(hook.result.current.visible).toBe(false);
await waitFor(() => expect(hook.result.current.fields).toEqual([]));
expect(hook.result.current.visible).toBe(false);
});
it('FE-COMP-PLUGIN-USERSET-003: an OAuth connection alone makes the card visible', async () => {
vi.mocked(pluginsApi.userSettings).mockResolvedValue({ fields: [], config: {}, actions: [] });
vi.mocked(pluginsApi.oauthStatus).mockResolvedValue({ configured: true, connected: false });
const { result } = renderHook(() => usePluginUserSettings('p'));
await waitFor(() => expect(result.current.visible).toBe(true));
expect(result.current.oauth).toEqual({ configured: true, connected: false });
});
it('FE-COMP-PLUGIN-USERSET-004: a failed read leaves an empty field list and no OAuth', async () => {
vi.mocked(pluginsApi.userSettings).mockRejectedValue(new Error('x'));
vi.mocked(pluginsApi.oauthStatus).mockRejectedValue(new Error('x'));
const { result } = renderHook(() => usePluginUserSettings('p'));
await waitFor(() => expect(result.current.fields).toEqual([]));
expect(result.current.oauth).toBeNull();
});
it('FE-COMP-PLUGIN-USERSET-005: save refuses a blank required field by name, without a request', async () => {
const { result } = await loaded();
act(() => result.current.setValue('units', ' '));
await act(() => result.current.save());
expect(toast.error).toHaveBeenCalledWith('settings.plugins.requiredMissing:units');
expect(pluginsApi.saveUserSettings).not.toHaveBeenCalled();
});
it('FE-COMP-PLUGIN-USERSET-006: save leaves the untouched secret out and reseeds from the answer', async () => {
const { result } = await loaded();
act(() => result.current.setValue('units', 'mi'));
act(() => result.current.setValue('on', false));
await act(() => result.current.save());
expect(pluginsApi.saveUserSettings).toHaveBeenCalledWith('p', { units: 'mi', on: false });
expect(result.current.values).toEqual({ token: SECRET_MASK, units: 'mi', on: false });
expect(toast.success).toHaveBeenCalledWith('settings.plugins.saved');
expect(result.current.saving).toBe(false);
});
it('FE-COMP-PLUGIN-USERSET-007: a 4xx save shows the server reason, a 5xx the generic error', async () => {
const { result } = await loaded();
vi.mocked(pluginsApi.saveUserSettings).mockRejectedValueOnce({
response: { status: 400, data: { error: 'nope' } },
});
await act(() => result.current.save());
expect(toast.error).toHaveBeenLastCalledWith('nope');
vi.mocked(pluginsApi.saveUserSettings).mockRejectedValueOnce({
response: { status: 500, data: { error: 'trace' } },
});
await act(() => result.current.save());
expect(toast.error).toHaveBeenLastCalledWith('common.error');
});
it('FE-COMP-PLUGIN-USERSET-008: a safe action runs straight away and records its result', async () => {
const { result } = await loaded();
act(() => result.current.runAction(PING));
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }));
expect(pluginsApi.runAction).toHaveBeenCalledWith('p', 'ping');
expect(result.current.running).toBeNull();
expect(result.current.pendingAction).toBeNull();
});
it('FE-COMP-PLUGIN-USERSET-009: a dangerous action waits for the confirm step', async () => {
const { result } = await loaded();
act(() => result.current.runAction(WIPE));
expect(result.current.pendingAction).toBe(WIPE);
expect(pluginsApi.runAction).not.toHaveBeenCalled();
vi.mocked(pluginsApi.runAction).mockRejectedValueOnce(new Error('x'));
await act(() => result.current.performAction(WIPE));
expect(result.current.actionResult.wipe).toEqual({ ok: false, message: 'common.error' });
});
});
describe('usePluginOAuth', () => {
it('FE-COMP-PLUGIN-USERSET-010: disconnect clears the connection, a failure toasts and frees the button', async () => {
const setState = vi.fn();
vi.spyOn(pluginsApi, 'oauthDisconnect').mockResolvedValue({ connected: false });
const { result } = renderHook(() => usePluginOAuth('p', { configured: true, connected: true }, setState));
await act(() => result.current.disconnect());
expect(pluginsApi.oauthDisconnect).toHaveBeenCalledWith('p');
expect(setState).toHaveBeenCalledWith({ configured: true, connected: false });
vi.mocked(pluginsApi.oauthDisconnect).mockRejectedValueOnce(new Error('x'));
await act(() => result.current.disconnect());
expect(toast.error).toHaveBeenCalledWith('common.error');
expect(result.current.busy).toBe(false);
});
it('FE-COMP-PLUGIN-USERSET-011: a failed connect toasts and frees the button', async () => {
vi.spyOn(pluginsApi, 'oauthConnect').mockRejectedValue(new Error('x'));
const { result } = renderHook(() => usePluginOAuth('p', { configured: true, connected: false }, vi.fn()));
await act(() => result.current.connect());
expect(toast.error).toHaveBeenCalledWith('common.error');
expect(result.current.busy).toBe(false);
});
it('FE-COMP-PLUGIN-USERSET-012: a connect hands off to the provider and keeps the button busy', async () => {
const realLocation = window.location;
const loc = { href: 'http://localhost/settings' };
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: loc });
try {
vi.spyOn(pluginsApi, 'oauthConnect').mockResolvedValue({
authorizeUrl: 'https://provider.example/authorize?x=1',
});
const { result } = renderHook(() => usePluginOAuth('p', { configured: true, connected: false }, vi.fn()));
await act(() => result.current.connect());
expect(pluginsApi.oauthConnect).toHaveBeenCalledWith('p');
expect(loc.href).toBe('https://provider.example/authorize?x=1');
expect(result.current.busy).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation });
}
});
});
@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { pluginsApi, type PluginAction, type PluginUserSettingField } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useToast } from '../shared/Toast';
import { findMissingRequired, seedSettingsValues, settingsPatch, type SettingsValues } from './settingsForm';
export interface PluginOAuthState {
configured: boolean;
connected: boolean;
}
export interface PluginActionOutcome {
ok: boolean;
message?: string;
}
/**
* A user's own settings for one plugin, behind the desktop settings tab and the
* phone screen alike: the declared `scope:'user'` fields and their draft values,
* the declared actions with their results, and the host-brokered OAuth state. Each
* shell renders its own form, confirm step and picker over this.
*/
export function usePluginUserSettings(id: string) {
const { t } = useTranslation();
const toast = useToast();
const [fields, setFields] = useState<PluginUserSettingField[] | null>(null);
const [values, setValues] = useState<SettingsValues>({});
const [saving, setSaving] = useState(false);
const [oauth, setOauth] = useState<PluginOAuthState | null>(null);
const [actions, setActions] = useState<PluginAction[]>([]);
const [running, setRunning] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<Record<string, PluginActionOutcome>>({});
// A dangerous action waits here for the user's answer in the confirm step.
const [pendingAction, setPendingAction] = useState<PluginAction | null>(null);
useEffect(() => {
let alive = true;
pluginsApi
.userSettings(id)
.then((r) => {
if (!alive) return;
setFields(r.fields);
setActions(r.actions ?? []);
setValues(seedSettingsValues(r.fields, r.config));
})
.catch(() => {
if (alive) setFields([]);
});
pluginsApi
.oauthStatus(id)
.then((s) => {
if (alive) setOauth(s);
})
.catch(() => {
if (alive) setOauth(null);
});
return () => {
alive = false;
};
}, [id]);
const hasFields = (fields?.length ?? 0) > 0;
// Show the card if the plugin has user fields, actions, OR an OAuth connection to offer.
const visible = fields !== null && (hasFields || actions.length > 0 || !!oauth?.configured);
// An action runs AS the caller, so it sees the values they just saved. Running the save
// first if the form is dirty would be nicer, but keeping it explicit is less surprising.
const performAction = async (a: PluginAction) => {
setRunning(a.key);
try {
const res = await pluginsApi.runAction(id, a.key);
setActionResult((prev) => ({ ...prev, [a.key]: res }));
} catch {
setActionResult((prev) => ({ ...prev, [a.key]: { ok: false, message: t('common.error') } }));
} finally {
setRunning(null);
}
};
const runAction = (a: PluginAction) => {
if (a.danger) {
setPendingAction(a);
return;
}
void performAction(a);
};
const save = async () => {
if (!fields) return;
const missing = findMissingRequired(fields, values);
if (missing) {
toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key }));
return;
}
setSaving(true);
try {
const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values));
setValues(seedSettingsValues(fields, r.config));
toast.success(t('settings.plugins.saved'));
} catch (e) {
// A 4xx names what the server refused (a required field it knows about and this
// stale field list doesn't); a 5xx body is not for the user.
const err = e as { response?: { status?: number; data?: { error?: string } } };
const refused = err.response?.status && err.response.status < 500 ? err.response.data?.error : undefined;
toast.error(refused || t('common.error'));
} finally {
setSaving(false);
}
};
const setValue = (key: string, value: string | boolean) => setValues((v) => ({ ...v, [key]: value }));
return {
fields,
values,
setValue,
hasFields,
visible,
saving,
save,
actions,
running,
actionResult,
runAction,
performAction,
pendingAction,
setPendingAction,
oauth,
setOauth,
};
}
/**
* Host-brokered OAuth for one plugin: the host runs the whole flow and holds the
* tokens, so this only starts a connect (a redirect to the provider) or a disconnect.
*/
export function usePluginOAuth(id: string, state: PluginOAuthState | null, setState: (s: PluginOAuthState) => void) {
const { t } = useTranslation();
const toast = useToast();
const [busy, setBusy] = useState(false);
const connect = async () => {
setBusy(true);
try {
const { authorizeUrl } = await pluginsApi.oauthConnect(id);
window.location.href = authorizeUrl; // hand off to the provider; returns to /settings
} catch {
toast.error(t('common.error'));
setBusy(false);
}
};
const disconnect = async () => {
if (!state) return;
setBusy(true);
try {
await pluginsApi.oauthDisconnect(id);
setState({ ...state, connected: false });
} catch {
toast.error(t('common.error'));
} finally {
setBusy(false);
}
};
return { busy, connect, disconnect };
}
@@ -1,19 +1,9 @@
import { useEffect, useState } from 'react'
import { History, RefreshCw } from 'lucide-react'
import { pluginsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { SettingsCard, SettingsHint, SETTINGS_ICON_BUTTON } from './settingsKit'
interface ActivityRow {
ts: string
plugin_id: string
plugin_name: string | null
method: string
resource: string | null
code: string
}
import { usePluginActivity } from './usePluginActivity'
/**
* Tailwind classes for a result-code pill. "ok" stays neutral; an access denial
@@ -37,24 +27,8 @@ const TD = 'px-3 py-3 align-middle'
* shows the empty state, never a crash.
*/
export default function PluginActivityPanel() {
const { t, locale } = useTranslation()
const [rows, setRows] = useState<ActivityRow[]>([])
const [loading, setLoading] = useState(true)
const load = () => {
setLoading(true)
pluginsApi.myActivity()
.then(r => setRows(r.activity))
.catch(() => setRows([]))
.finally(() => setLoading(false))
}
useEffect(() => { load() }, [])
const fmtWhen = (ts: string): string => {
const d = new Date(ts)
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale)
}
const { t } = useTranslation()
const { rows, loading, load, fmtWhen } = usePluginActivity()
const refreshLabel = t('settings.pluginActivity.refresh')
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useState, type ReactNode } from 'react'
import { Fragment, type ReactNode } from 'react'
import { Save, Loader2, Link2, Unlink, CheckCircle, Puzzle, Zap } from 'lucide-react'
import { resolvePluginIcon } from '../shared/PluginIcon'
import CustomSelect from '../shared/CustomSelect'
@@ -7,12 +7,11 @@ import { fs } from '../shared/DialogShell'
import { EditorField, INPUT } from '../shared/dialogParts'
import { SettingRow, SettingRows, SettingsCard, SettingsHint, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
import PluginFrame from '../Plugins/PluginFrame'
import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client'
import type { PluginUserSettingField } from '../../api/client'
import { usePluginStore } from '../../store/pluginStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import PluginActivityPanel from './PluginActivityPanel'
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
import { usePluginOAuth, usePluginUserSettings, type PluginOAuthState } from '../Plugins/usePluginUserSettings'
type FieldGroup = { kind: 'switches' | 'fields'; fields: PluginUserSettingField[] }
@@ -52,31 +51,14 @@ function CheckSwitch({ id, checked, onChange }: { id: string; checked: boolean;
* holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */
function PluginOAuthSection({ id, state, setState }: {
id: string
state: { configured: boolean; connected: boolean } | null
setState: (s: { configured: boolean; connected: boolean }) => void
state: PluginOAuthState | null
setState: (s: PluginOAuthState) => void
}) {
const { t } = useTranslation()
const toast = useToast()
const [busy, setBusy] = useState(false)
const { busy, connect, disconnect } = usePluginOAuth(id, state, setState)
if (!state?.configured) return null
const connect = async () => {
setBusy(true)
try {
const { authorizeUrl } = await pluginsApi.oauthConnect(id)
window.location.href = authorizeUrl // hand off to the provider; returns to /settings
} catch {
toast.error(t('common.error')); setBusy(false)
}
}
const disconnect = async () => {
setBusy(true)
try { await pluginsApi.oauthDisconnect(id); setState({ ...state, connected: false }) }
catch { toast.error(t('common.error')) }
finally { setBusy(false) }
}
return (
<SettingRows>
<SettingRow
@@ -100,78 +82,28 @@ function PluginOAuthSection({ id, state, setState }: {
*/
function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon: string | null }) {
const { t } = useTranslation()
const toast = useToast()
const [fields, setFields] = useState<PluginUserSettingField[] | null>(null)
const [values, setValues] = useState<Record<string, string | boolean>>({})
const [saving, setSaving] = useState(false)
const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null)
const [actions, setActions] = useState<PluginAction[]>([])
const [running, setRunning] = useState<string | null>(null)
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message?: string }>>({})
// A dangerous action waits here for the user's answer in the confirm dialog.
const [pendingAction, setPendingAction] = useState<PluginAction | null>(null)
const {
fields,
values,
setValue,
hasFields,
visible,
saving,
save,
actions,
running,
actionResult,
runAction,
performAction,
pendingAction,
setPendingAction,
oauth,
setOauth,
} = usePluginUserSettings(id)
useEffect(() => {
let alive = true
pluginsApi.userSettings(id)
.then(r => {
if (!alive) return
setFields(r.fields)
setActions(r.actions ?? [])
setValues(seedSettingsValues(r.fields, r.config))
})
.catch(() => { if (alive) setFields([]) })
pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) })
return () => { alive = false }
}, [id])
const hasFields = (fields?.length ?? 0) > 0
// Show the card if the plugin has user fields, actions, OR an OAuth connection to offer.
if (fields === null || (!hasFields && actions.length === 0 && !oauth?.configured)) return null
// An action runs AS the caller, so it sees the values they just saved — run the save
// first if the form is dirty would be nicer, but keeping it explicit is less surprising.
const runAction = (a: PluginAction) => {
if (a.danger) { setPendingAction(a); return }
void performAction(a)
}
const performAction = async (a: PluginAction) => {
setRunning(a.key)
try {
const res = await pluginsApi.runAction(id, a.key)
setActionResult(prev => ({ ...prev, [a.key]: res }))
} catch {
setActionResult(prev => ({ ...prev, [a.key]: { ok: false, message: t('common.error') } }))
} finally {
setRunning(null)
}
}
const save = async () => {
const missing = findMissingRequired(fields, values)
if (missing) {
toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key }))
return
}
setSaving(true)
try {
const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values))
setValues(seedSettingsValues(fields, r.config))
toast.success(t('settings.plugins.saved'))
} catch (e) {
// A 4xx names what the server refused (a required field it knows about and this
// stale field list doesn't); a 5xx body is not for the user.
const err = e as { response?: { status?: number; data?: { error?: string } } }
const refused = err.response?.status && err.response.status < 500 ? err.response.data?.error : undefined
toast.error(refused || t('common.error'))
} finally {
setSaving(false)
}
}
if (!visible || fields === null) return null
const fieldId = (key: string) => `plugin-setting-${id}-${key}`
const setValue = (key: string, value: string | boolean) => setValues(v => ({ ...v, [key]: value }))
const labelOf = (f: PluginUserSettingField): ReactNode => (
<>{f.label || f.key}{f.required && <span className="text-danger"> *</span>}</>
)
@@ -0,0 +1,54 @@
// FE-COMP-PLUGIN-ACTIVITY-HOOK-001 to -004: the plugin activity log both settings shells share.
import { act, renderHook, waitFor } from '@testing-library/react';
import { pluginsApi } from '../../api/client';
import { usePluginActivity } from './usePluginActivity';
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k, locale: 'en-US' }) }));
const ROW = {
ts: '2026-01-02T03:04:05Z',
plugin_id: 'p',
plugin_name: 'P',
method: 'trips.read',
resource: 'trip:1',
code: 'ok',
};
afterEach(() => vi.restoreAllMocks());
describe('usePluginActivity', () => {
it('FE-COMP-PLUGIN-ACTIVITY-HOOK-001: loads on mount and ends loading', async () => {
vi.spyOn(pluginsApi, 'myActivity').mockResolvedValue({ activity: [ROW] });
const { result } = renderHook(() => usePluginActivity());
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.rows).toEqual([ROW]);
expect(pluginsApi.myActivity).toHaveBeenCalledTimes(1);
});
it('FE-COMP-PLUGIN-ACTIVITY-HOOK-002: a failed load shows an empty log instead of an error', async () => {
vi.spyOn(pluginsApi, 'myActivity').mockRejectedValue(new Error('x'));
const { result } = renderHook(() => usePluginActivity());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.rows).toEqual([]);
});
it('FE-COMP-PLUGIN-ACTIVITY-HOOK-003: refresh loads again and replaces the rows', async () => {
const spy = vi.spyOn(pluginsApi, 'myActivity').mockResolvedValue({ activity: [] });
const { result } = renderHook(() => usePluginActivity());
await waitFor(() => expect(result.current.loading).toBe(false));
spy.mockResolvedValue({ activity: [ROW] });
act(() => result.current.load());
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.rows).toEqual([ROW]));
expect(spy).toHaveBeenCalledTimes(2);
});
it('FE-COMP-PLUGIN-ACTIVITY-HOOK-004: formats a timestamp in the locale and passes an unreadable one through', async () => {
vi.spyOn(pluginsApi, 'myActivity').mockResolvedValue({ activity: [] });
const { result } = renderHook(() => usePluginActivity());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.fmtWhen(ROW.ts)).toBe(new Date(ROW.ts).toLocaleString('en-US'));
expect(result.current.fmtWhen('not a date')).toBe('not a date');
});
});
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { pluginsApi } from '../../api/client';
import { useTranslation } from '../../i18n';
export interface ActivityRow {
ts: string;
plugin_id: string;
plugin_name: string | null;
method: string;
resource: string | null;
code: string;
}
/**
* The signed-in user's own plugin activity log behind the desktop panel and the
* phone card: loaded on mount and on refresh, newest first. Fail-safe: a failed
* load leaves an empty list, never an error.
*/
export function usePluginActivity() {
const { locale } = useTranslation();
const [rows, setRows] = useState<ActivityRow[]>([]);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
pluginsApi
.myActivity()
.then((r) => setRows(r.activity))
.catch(() => setRows([]))
.finally(() => setLoading(false));
};
useEffect(() => {
load();
}, []);
const fmtWhen = (ts: string): string => {
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale);
};
return { rows, loading, load, fmtWhen };
}
@@ -5,49 +5,35 @@
* the user's plugin activity log), rebuilt on the MSet* card system: MToggle for
* booleans, a picker sheet for selects and an MConfirmSheet for danger actions.
*/
import { Fragment, useEffect, useState } from 'react'
import { Fragment, useState } from 'react'
import { Save, Loader2, Link2, Unlink, CheckCircle, ChevronDown, History, RefreshCw } from 'lucide-react'
import { resolvePluginIcon } from '../../../components/shared/PluginIcon'
import PluginFrame from '../../../components/Plugins/PluginFrame'
import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../../api/client'
import { usePluginStore } from '../../../store/pluginStore'
import { useToast } from '../../../components/shared/Toast'
import { useTranslation } from '../../../i18n'
import { MSetCard, MSetEyebrow, MSetSelectRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MToggle from '../../components/MToggle'
import MConfirmSheet from './MConfirmSheet'
import MSetPickerSheet from './MSetPickerSheet'
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../../../components/Plugins/settingsForm'
import {
usePluginOAuth,
usePluginUserSettings,
type PluginOAuthState,
} from '../../../components/Plugins/usePluginUserSettings'
import { usePluginActivity } from '../../../components/Settings/usePluginActivity'
/** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow +
* holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */
function PluginOAuthSection({ id, state, setState }: {
id: string
state: { configured: boolean; connected: boolean } | null
setState: (s: { configured: boolean; connected: boolean }) => void
state: PluginOAuthState | null
setState: (s: PluginOAuthState) => void
}) {
const { t } = useTranslation()
const toast = useToast()
const [busy, setBusy] = useState(false)
const { busy, connect, disconnect } = usePluginOAuth(id, state, setState)
if (!state?.configured) return null
const connect = async () => {
setBusy(true)
try {
const { authorizeUrl } = await pluginsApi.oauthConnect(id)
window.location.href = authorizeUrl // hand off to the provider; returns to /settings
} catch {
toast.error(t('common.error')); setBusy(false)
}
}
const disconnect = async () => {
setBusy(true)
try { await pluginsApi.oauthDisconnect(id); setState({ ...state, connected: false }) }
catch { toast.error(t('common.error')) }
finally { setBusy(false) }
}
return (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] pt-3">
<span className="flex items-center gap-[6px] text-[0.78125rem] font-semibold text-m-muted">
@@ -71,76 +57,28 @@ function PluginOAuthSection({ id, state, setState }: {
*/
function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon: string | null }) {
const { t } = useTranslation()
const toast = useToast()
const [fields, setFields] = useState<PluginUserSettingField[] | null>(null)
const [values, setValues] = useState<Record<string, string | boolean>>({})
const [saving, setSaving] = useState(false)
const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null)
const [actions, setActions] = useState<PluginAction[]>([])
const [running, setRunning] = useState<string | null>(null)
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message?: string }>>({})
// Native sheets stand in for the desktop <select> and window.confirm.
const {
fields,
values,
setValue,
hasFields,
visible,
saving,
save,
actions,
running,
actionResult,
runAction,
performAction,
pendingAction,
setPendingAction,
oauth,
setOauth,
} = usePluginUserSettings(id)
// Native sheet stands in for the desktop <select>.
const [pickerKey, setPickerKey] = useState<string | null>(null)
const [confirmAction, setConfirmAction] = useState<PluginAction | null>(null)
useEffect(() => {
let alive = true
pluginsApi.userSettings(id)
.then(r => {
if (!alive) return
setFields(r.fields)
setActions(r.actions ?? [])
setValues(seedSettingsValues(r.fields, r.config))
})
.catch(() => { if (alive) setFields([]) })
pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) })
return () => { alive = false }
}, [id])
const hasFields = (fields?.length ?? 0) > 0
// Show the card if the plugin has user fields, actions, OR an OAuth connection to offer.
if (fields === null || (!hasFields && actions.length === 0 && !oauth?.configured)) return null
// An action runs AS the caller, so it sees the values they just saved — run the save
// first if the form is dirty would be nicer, but keeping it explicit is less surprising.
const performAction = async (a: PluginAction) => {
setRunning(a.key)
try {
const res = await pluginsApi.runAction(id, a.key)
setActionResult(prev => ({ ...prev, [a.key]: res }))
} catch {
setActionResult(prev => ({ ...prev, [a.key]: { ok: false, message: t('common.error') } }))
} finally {
setRunning(null)
}
}
const runAction = (a: PluginAction) => {
// Danger actions confirm first (native sheet in place of window.confirm).
if (a.danger) { setConfirmAction(a); return }
void performAction(a)
}
const save = async () => {
const missing = findMissingRequired(fields, values)
if (missing) {
toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key }))
return
}
setSaving(true)
try {
const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values))
setValues(seedSettingsValues(fields, r.config))
toast.success(t('settings.plugins.saved'))
} catch (e) {
// A 4xx names what the server refused (a required field it knows about and this
// stale field list doesn't); a 5xx body is not for the user.
const err = e as { response?: { status?: number; data?: { error?: string } } }
const refused = err.response?.status && err.response.status < 500 ? err.response.data?.error : undefined
toast.error(refused || t('common.error'))
} finally {
setSaving(false)
}
}
if (!visible || fields === null) return null
const pickerField = fields.find(f => f.key === pickerKey && f.input_type === 'select' && !!f.options)
@@ -160,7 +98,7 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
</div>
<MToggle
checked={values[f.key] === true}
onChange={checked => setValues(v => ({ ...v, [f.key]: checked }))}
onChange={checked => setValue(f.key, checked)}
ariaLabel={f.label || f.key}
/>
</div>
@@ -191,7 +129,7 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
value={String(values[f.key] ?? '')}
placeholder={f.placeholder || ''}
autoComplete={f.secret ? 'new-password' : 'off'}
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.value }))}
onChange={e => setValue(f.key, e.target.value)}
/>
{f.hint && <MSetHint>{f.hint}</MSetHint>}
</div>
@@ -246,19 +184,19 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
onClose={() => setPickerKey(null)}
title={pickerField ? (pickerField.label || pickerField.key) : ''}
value={String(values[pickerField?.key ?? ''] ?? '')}
onSelect={(val) => { if (pickerField) setValues(v => ({ ...v, [pickerField.key]: val })) }}
onSelect={(val) => { if (pickerField) setValue(pickerField.key, val) }}
options={pickerField ? [{ value: '', label: '—' }, ...pickerField.options!.map(o => ({ value: o.value, label: o.label }))] : []}
/>
<MConfirmSheet
open={confirmAction != null}
onClose={() => setConfirmAction(null)}
title={confirmAction?.label ?? ''}
open={pendingAction != null}
onClose={() => setPendingAction(null)}
title={pendingAction?.label ?? ''}
message={t('settings.plugins.actions.confirm')}
confirmLabel={confirmAction?.label}
confirmLabel={pendingAction?.label}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => { const a = confirmAction; setConfirmAction(null); if (a) void performAction(a) }}
onConfirm={() => { const a = pendingAction; setPendingAction(null); if (a) void performAction(a) }}
/>
</>
)
@@ -286,15 +224,6 @@ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; ic
)
}
interface ActivityRow {
ts: string
plugin_id: string
plugin_name: string | null
method: string
resource: string | null
code: string
}
/**
* Status-code tone for the result pill. "ok" stays neutral; an access denial reads
* as danger, anything else non-ok as a softer pending/warning tone.
@@ -311,24 +240,8 @@ function codeTone(code: string): string {
* capability audit. Fail-safe: a failed load just shows the empty state.
*/
function PluginActivityPanel() {
const { t, locale } = useTranslation()
const [rows, setRows] = useState<ActivityRow[]>([])
const [loading, setLoading] = useState(true)
const load = () => {
setLoading(true)
pluginsApi.myActivity()
.then(r => setRows(r.activity))
.catch(() => setRows([]))
.finally(() => setLoading(false))
}
useEffect(() => { load() }, [])
const fmtWhen = (ts: string): string => {
const d = new Date(ts)
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale)
}
const { t } = useTranslation()
const { rows, loading, load, fmtWhen } = usePluginActivity()
const refresh = (
<button