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