From f2fd314dd650f4d729f5d0129357e39c34aae082 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 05:23:04 -0400 Subject: [PATCH] refactor(decnet_web/Config): extract useConfig data hook Lift the GET /config fetch and every admin-side mutation off the page shell: GET /config PUT /config/deployment-limit PUT /config/global-mutation-interval POST /config/users DELETE /config/users/:uuid PUT /config/users/:uuid/role PUT /config/users/:uuid/reset-password DELETE /config/reinit (returns { logs, bounties, attackers }) Mutations return { ok: true } | { ok: false; reason: string } so the upcoming tab components can render the inline FormMsg chip without touching axios error shapes. reinit additionally returns the deletion totals so the danger-zone confirmation can echo "PURGED: N logs, N bounties, N attackers". - New Config/useConfig.ts - useConfig.test.ts MSW-covers initial load, isAdmin role surfacing, setDeploymentLimit ok + 400 paths, addUser, deleteUser refused, and reinit success. - Wiring into Config.tsx + tab extractions land in follow-up commits. --- .../src/components/Config/useConfig.test.ts | 108 +++++++++++ decnet_web/src/components/Config/useConfig.ts | 179 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 decnet_web/src/components/Config/useConfig.test.ts create mode 100644 decnet_web/src/components/Config/useConfig.ts diff --git a/decnet_web/src/components/Config/useConfig.test.ts b/decnet_web/src/components/Config/useConfig.test.ts new file mode 100644 index 00000000..604e1429 --- /dev/null +++ b/decnet_web/src/components/Config/useConfig.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse, server, apiUrl } from '../../test/server'; + +import { useConfig } from './useConfig'; + +const adminConfigHandlers = () => [ + http.get(apiUrl('/config'), () => + HttpResponse.json({ + role: 'admin', + deployment_limit: 50, + global_mutation_interval: '30m', + users: [ + { uuid: 'u-1', username: 'alice', role: 'admin', must_change_password: false }, + ], + developer_mode: true, + }), + ), +]; + +describe('useConfig', () => { + it('loads /config on mount and surfaces isAdmin from the role', async () => { + server.use(...adminConfigHandlers()); + const { result } = renderHook(() => useConfig()); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isAdmin).toBe(true); + expect(result.current.config?.deployment_limit).toBe(50); + }); + + it('setDeploymentLimit returns ok on 200 and reloads', async () => { + server.use( + ...adminConfigHandlers(), + http.put(apiUrl('/config/deployment-limit'), () => HttpResponse.json({})), + ); + const { result } = renderHook(() => useConfig()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { r = await result.current.setDeploymentLimit(120); }); + expect(r).toEqual({ ok: true }); + }); + + it('setDeploymentLimit surfaces server detail on error', async () => { + server.use( + ...adminConfigHandlers(), + http.put(apiUrl('/config/deployment-limit'), () => + HttpResponse.json({ detail: 'too high' }, { status: 400 }), + ), + ); + const { result } = renderHook(() => useConfig()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { r = await result.current.setDeploymentLimit(999); }); + expect(r).toEqual({ ok: false, reason: 'too high' }); + }); + + it('addUser returns ok and reloads', async () => { + server.use( + ...adminConfigHandlers(), + http.post(apiUrl('/config/users'), () => HttpResponse.json({})), + ); + const { result } = renderHook(() => useConfig()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { + r = await result.current.addUser({ + username: 'bob', password: 'hunter22ish', role: 'viewer', + }); + }); + expect(r).toEqual({ ok: true }); + }); + + it('deleteUser surfaces error detail', async () => { + server.use( + ...adminConfigHandlers(), + http.delete(apiUrl('/config/users/u-1'), () => + HttpResponse.json({ detail: 'cannot delete last admin' }, { status: 409 }), + ), + ); + const { result } = renderHook(() => useConfig()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { r = await result.current.deleteUser('u-1'); }); + expect(r).toEqual({ ok: false, reason: 'cannot delete last admin' }); + }); + + it('reinit returns deleted totals on success', async () => { + server.use( + ...adminConfigHandlers(), + http.delete(apiUrl('/config/reinit'), () => + HttpResponse.json({ deleted: { logs: 1234, bounties: 7, attackers: 42 } }), + ), + ); + const { result } = renderHook(() => useConfig()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { r = await result.current.reinit(); }); + expect(r).toEqual({ ok: true, deleted: { logs: 1234, bounties: 7, attackers: 42 } }); + }); +}); diff --git a/decnet_web/src/components/Config/useConfig.ts b/decnet_web/src/components/Config/useConfig.ts new file mode 100644 index 00000000..53c04ab3 --- /dev/null +++ b/decnet_web/src/components/Config/useConfig.ts @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import api from '../../utils/api'; +import type { ConfigData } from './types'; + +/** Discriminated result shape used by every Config mutation. The + * Config tabs translate this into the inline FormMsg chip — keeps + * the hook free of UI concerns. */ +export type ConfigMutationResult = + | { ok: true } + | { ok: false; reason: string }; + +export interface ReinitTotals { + logs: number; + bounties: number; + attackers: number; +} + +export type ReinitResult = + | { ok: true; deleted: ReinitTotals } + | { ok: false; reason: string }; + +export interface UseConfigResult { + config: ConfigData | null; + loading: boolean; + isAdmin: boolean; + reload: () => Promise; + + // Settings + setDeploymentLimit: (n: number) => Promise; + setGlobalMutationInterval: (s: string) => Promise; + + // Users + addUser: (input: { + username: string; + password: string; + role: 'admin' | 'viewer'; + }) => Promise; + deleteUser: (uuid: string) => Promise; + setUserRole: (uuid: string, role: string) => Promise; + resetUserPassword: (uuid: string, newPassword: string) => Promise; + + // Danger zone + reinit: () => Promise; +} + +const errMsg = (err: unknown, fallback: string): string => { + const e = err as { response?: { data?: { detail?: string } } }; + return e?.response?.data?.detail || fallback; +}; + +/** Owns the GET /config fetch and all admin mutations. Mutation + * results carry their own error string so callers can render an + * inline FormMsg without re-parsing axios errors. */ +export function useConfig(): UseConfigResult { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + + const reload = useCallback(async () => { + try { + const res = await api.get('/config'); + setConfig(res.data); + } catch (err) { + console.error('Failed to fetch config', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void reload(); }, [reload]); + + const setDeploymentLimit = useCallback( + async (n: number): Promise => { + try { + await api.put('/config/deployment-limit', { deployment_limit: n }); + await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: errMsg(err, 'UPDATE FAILED') }; + } + }, + [reload], + ); + + const setGlobalMutationInterval = useCallback( + async (s: string): Promise => { + try { + await api.put('/config/global-mutation-interval', { global_mutation_interval: s }); + await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: errMsg(err, 'UPDATE FAILED') }; + } + }, + [reload], + ); + + const addUser = useCallback( + async (input: { + username: string; + password: string; + role: 'admin' | 'viewer'; + }): Promise => { + try { + await api.post('/config/users', input); + await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: errMsg(err, 'CREATE FAILED') }; + } + }, + [reload], + ); + + const deleteUser = useCallback( + async (uuid: string): Promise => { + try { + await api.delete(`/config/users/${uuid}`); + await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: errMsg(err, 'Delete failed') }; + } + }, + [reload], + ); + + const setUserRole = useCallback( + async (uuid: string, role: string): Promise => { + try { + await api.put(`/config/users/${uuid}/role`, { role }); + await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: errMsg(err, 'Role update failed') }; + } + }, + [reload], + ); + + const resetUserPassword = useCallback( + async (uuid: string, newPassword: string): Promise => { + try { + await api.put(`/config/users/${uuid}/reset-password`, { new_password: newPassword }); + await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: errMsg(err, 'Password reset failed') }; + } + }, + [reload], + ); + + const reinit = useCallback(async (): Promise => { + try { + const res = await api.delete('/config/reinit'); + const deleted = res.data?.deleted as ReinitTotals; + return { ok: true, deleted }; + } catch (err) { + return { ok: false, reason: errMsg(err, 'REINIT FAILED') }; + } + }, []); + + const isAdmin = config?.role === 'admin'; + + return useMemo( + () => ({ + config, loading, isAdmin, reload, + setDeploymentLimit, setGlobalMutationInterval, + addUser, deleteUser, setUserRole, resetUserPassword, + reinit, + }), + [ + config, loading, isAdmin, reload, + setDeploymentLimit, setGlobalMutationInterval, + addUser, deleteUser, setUserRole, resetUserPassword, + reinit, + ], + ); +}