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.
This commit is contained in:
2026-05-09 05:23:04 -04:00
parent b1fbf4630e
commit f2fd314dd6
2 changed files with 287 additions and 0 deletions

View File

@@ -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<ReturnType<typeof result.current.setDeploymentLimit>> | 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<ReturnType<typeof result.current.setDeploymentLimit>> | 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<ReturnType<typeof result.current.addUser>> | 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<ReturnType<typeof result.current.deleteUser>> | 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<ReturnType<typeof result.current.reinit>> | undefined;
await act(async () => { r = await result.current.reinit(); });
expect(r).toEqual({ ok: true, deleted: { logs: 1234, bounties: 7, attackers: 42 } });
});
});

View File

@@ -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<void>;
// Settings
setDeploymentLimit: (n: number) => Promise<ConfigMutationResult>;
setGlobalMutationInterval: (s: string) => Promise<ConfigMutationResult>;
// Users
addUser: (input: {
username: string;
password: string;
role: 'admin' | 'viewer';
}) => Promise<ConfigMutationResult>;
deleteUser: (uuid: string) => Promise<ConfigMutationResult>;
setUserRole: (uuid: string, role: string) => Promise<ConfigMutationResult>;
resetUserPassword: (uuid: string, newPassword: string) => Promise<ConfigMutationResult>;
// Danger zone
reinit: () => Promise<ReinitResult>;
}
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<ConfigData | null>(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<ConfigMutationResult> => {
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<ConfigMutationResult> => {
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<ConfigMutationResult> => {
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<ConfigMutationResult> => {
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<ConfigMutationResult> => {
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<ConfigMutationResult> => {
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<ReinitResult> => {
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,
],
);
}