From 5ee7cc052c5153deb71617c9fb426f922011b677 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:22:40 +0200 Subject: [PATCH] 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. --- .../Plugins/usePluginUserSettings.test.ts | 169 ++++++++++++++++++ .../Plugins/usePluginUserSettings.ts | 166 +++++++++++++++++ .../Settings/PluginActivityPanel.tsx | 32 +--- .../components/Settings/PluginSettingsTab.tsx | 118 +++--------- .../Settings/usePluginActivity.test.ts | 54 ++++++ .../components/Settings/usePluginActivity.ts | 43 +++++ .../screens/settings/MSettingsPlugins.tsx | 167 +++++------------ 7 files changed, 500 insertions(+), 249 deletions(-) create mode 100644 client/src/components/Plugins/usePluginUserSettings.test.ts create mode 100644 client/src/components/Plugins/usePluginUserSettings.ts create mode 100644 client/src/components/Settings/usePluginActivity.test.ts create mode 100644 client/src/components/Settings/usePluginActivity.ts diff --git a/client/src/components/Plugins/usePluginUserSettings.test.ts b/client/src/components/Plugins/usePluginUserSettings.test.ts new file mode 100644 index 000000000..e3909ee7c --- /dev/null +++ b/client/src/components/Plugins/usePluginUserSettings.test.ts @@ -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) => (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 }); + } + }); +}); diff --git a/client/src/components/Plugins/usePluginUserSettings.ts b/client/src/components/Plugins/usePluginUserSettings.ts new file mode 100644 index 000000000..08634d2d6 --- /dev/null +++ b/client/src/components/Plugins/usePluginUserSettings.ts @@ -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(null); + const [values, setValues] = useState({}); + const [saving, setSaving] = useState(false); + const [oauth, setOauth] = useState(null); + const [actions, setActions] = useState([]); + const [running, setRunning] = useState(null); + const [actionResult, setActionResult] = useState>({}); + // A dangerous action waits here for the user's answer in the confirm step. + const [pendingAction, setPendingAction] = useState(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 }; +} diff --git a/client/src/components/Settings/PluginActivityPanel.tsx b/client/src/components/Settings/PluginActivityPanel.tsx index bed33bb8a..729a922a2 100644 --- a/client/src/components/Settings/PluginActivityPanel.tsx +++ b/client/src/components/Settings/PluginActivityPanel.tsx @@ -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([]) - 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') diff --git a/client/src/components/Settings/PluginSettingsTab.tsx b/client/src/components/Settings/PluginSettingsTab.tsx index d83115dc6..31cf782d3 100644 --- a/client/src/components/Settings/PluginSettingsTab.tsx +++ b/client/src/components/Settings/PluginSettingsTab.tsx @@ -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 ( (null) - const [values, setValues] = useState>({}) - const [saving, setSaving] = useState(false) - const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null) - const [actions, setActions] = useState([]) - const [running, setRunning] = useState(null) - const [actionResult, setActionResult] = useState>({}) - // A dangerous action waits here for the user's answer in the confirm dialog. - const [pendingAction, setPendingAction] = useState(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 && *} ) diff --git a/client/src/components/Settings/usePluginActivity.test.ts b/client/src/components/Settings/usePluginActivity.test.ts new file mode 100644 index 000000000..60291b15b --- /dev/null +++ b/client/src/components/Settings/usePluginActivity.test.ts @@ -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'); + }); +}); diff --git a/client/src/components/Settings/usePluginActivity.ts b/client/src/components/Settings/usePluginActivity.ts new file mode 100644 index 000000000..93c0d174d --- /dev/null +++ b/client/src/components/Settings/usePluginActivity.ts @@ -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([]); + 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 }; +} diff --git a/client/src/mobile/screens/settings/MSettingsPlugins.tsx b/client/src/mobile/screens/settings/MSettingsPlugins.tsx index 4e0171940..4856502be 100644 --- a/client/src/mobile/screens/settings/MSettingsPlugins.tsx +++ b/client/src/mobile/screens/settings/MSettingsPlugins.tsx @@ -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 (
@@ -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(null) - const [values, setValues] = useState>({}) - const [saving, setSaving] = useState(false) - const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null) - const [actions, setActions] = useState([]) - const [running, setRunning] = useState(null) - const [actionResult, setActionResult] = useState>({}) - // Native sheets stand in for the desktop . const [pickerKey, setPickerKey] = useState(null) - const [confirmAction, setConfirmAction] = useState(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
setValues(v => ({ ...v, [f.key]: checked }))} + onChange={checked => setValue(f.key, checked)} ariaLabel={f.label || f.key} /> @@ -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 && {f.hint}} @@ -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 }))] : []} /> 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([]) - 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 = (