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 = (