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:
108
decnet_web/src/components/Config/useConfig.test.ts
Normal file
108
decnet_web/src/components/Config/useConfig.test.ts
Normal 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 } });
|
||||
});
|
||||
});
|
||||
179
decnet_web/src/components/Config/useConfig.ts
Normal file
179
decnet_web/src/components/Config/useConfig.ts
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user