175 lines
8.4 KiB
TypeScript
175 lines
8.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { api, SettingsActor } from '../lib/api';
|
|
import { Card, StatusBadge } from '../components/ui/Card';
|
|
|
|
const ROLES = ['viewer', 'project-admin', 'approver', 'org-admin', 'auditor'];
|
|
const STATUS = ['pending', 'approved', 'rejected', 'auto_allowed', 'all'];
|
|
|
|
function parseValue(raw: string): unknown {
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
export function Approvals() {
|
|
const queryClient = useQueryClient();
|
|
const [actor, setActor] = useState<SettingsActor>({ actor: 'alice', role: 'project-admin', project: 'default', tenant: 'default' });
|
|
const [status, setStatus] = useState('pending');
|
|
const [key, setKey] = useState('security.strict');
|
|
const [value, setValue] = useState('true');
|
|
const [sensitive, setSensitive] = useState(true);
|
|
const [reason, setReason] = useState('review requested from Control Panel');
|
|
const [decisionReason, setDecisionReason] = useState('reviewed in approval inbox');
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
|
|
const inbox = useQuery({
|
|
queryKey: ['approvals', actor, status],
|
|
queryFn: () => api.approvals(actor, status),
|
|
retry: false,
|
|
});
|
|
|
|
const submit = useMutation({
|
|
mutationFn: () => api.submitApproval(actor, {
|
|
action: 'settings.write',
|
|
target: key,
|
|
risk: sensitive ? 'high' : 'standard',
|
|
sensitive,
|
|
reason,
|
|
payload: { key, value: parseValue(value) },
|
|
}),
|
|
onSuccess: (res) => {
|
|
setMessage(`submitted ${res.proposal.id} status=${res.proposal.status}`);
|
|
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
|
},
|
|
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'submit failed'),
|
|
});
|
|
|
|
const decide = useMutation({
|
|
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) =>
|
|
api.decideApproval(actor, { id, decision, reason: decisionReason }),
|
|
onSuccess: (res) => {
|
|
setMessage(`${res.proposal.status} ${res.proposal.id}${res.applied ? ` applied v${res.applied.version}` : ''}`);
|
|
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
|
},
|
|
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'decision failed'),
|
|
});
|
|
|
|
if (inbox.isLoading || !inbox.data) return <div className="text-gray-500">Loading…</div>;
|
|
|
|
return (
|
|
<>
|
|
<Card title="Approval identity" right={<StatusBadge value={inbox.data.audit_verify.ok ? 'audit ok' : 'audit fail'} />}>
|
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 text-sm">
|
|
<label className="space-y-1">
|
|
<span className="text-gray-500">Actor</span>
|
|
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
|
|
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-gray-500">Role</span>
|
|
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
|
|
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
|
|
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-gray-500">Project</span>
|
|
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
|
|
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-gray-500">Tenant</span>
|
|
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
|
|
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-gray-500">Status</span>
|
|
<select className="w-full rounded border border-gray-300 px-3 py-2" value={status} onChange={(e) => setStatus(e.target.value)}>
|
|
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title="Submit settings proposal">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
|
<label className="space-y-1">
|
|
<span className="text-gray-500">Setting key</span>
|
|
<input className="w-full rounded border border-gray-300 px-3 py-2" value={key} onChange={(e) => setKey(e.target.value)} />
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-gray-500">Value (JSON or string)</span>
|
|
<input className="w-full rounded border border-gray-300 px-3 py-2" value={value} onChange={(e) => setValue(e.target.value)} />
|
|
</label>
|
|
<label className="space-y-1 md:col-span-2">
|
|
<span className="text-gray-500">Reason</span>
|
|
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input type="checkbox" checked={sensitive} onChange={(e) => setSensitive(e.target.checked)} />
|
|
<span className="text-gray-700">Security-sensitive / high risk</span>
|
|
</label>
|
|
<div>
|
|
<button className="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
|
|
disabled={submit.isPending} onClick={() => submit.mutate()}>
|
|
Submit proposal
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{message && <div className="mt-3 rounded border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">{message}</div>}
|
|
</Card>
|
|
|
|
<Card title="Inbox">
|
|
<div className="space-y-3">
|
|
<label className="block space-y-1 text-sm">
|
|
<span className="text-gray-500">Decision reason</span>
|
|
<input className="w-full rounded border border-gray-300 px-3 py-2" value={decisionReason} onChange={(e) => setDecisionReason(e.target.value)} />
|
|
</label>
|
|
{inbox.data.proposals.map((p) => (
|
|
<div key={p.id} className="rounded border border-gray-200 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="font-medium text-gray-800">{p.id} · {p.action} · {p.target}</div>
|
|
<div className="text-xs text-gray-500">{p.proposer} · {p.created_at} · {p.delegation?.level} · {p.delegation?.reason}</div>
|
|
</div>
|
|
<StatusBadge value={p.status} />
|
|
</div>
|
|
<div className="mt-2 text-sm text-gray-600">{p.reason}</div>
|
|
<pre className="mt-2 overflow-auto rounded bg-gray-50 p-2 text-xs text-gray-700">{JSON.stringify(p.payload, null, 2)}</pre>
|
|
{p.status === 'pending' && (
|
|
<div className="mt-3 flex gap-2">
|
|
<button className="rounded bg-green-600 px-3 py-2 text-sm text-white disabled:bg-gray-300"
|
|
disabled={decide.isPending} onClick={() => decide.mutate({ id: p.id, decision: 'approve' })}>
|
|
Approve
|
|
</button>
|
|
<button className="rounded border border-gray-300 px-3 py-2 text-sm text-gray-700 disabled:text-gray-300"
|
|
disabled={decide.isPending} onClick={() => decide.mutate({ id: p.id, decision: 'reject' })}>
|
|
Reject
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{inbox.data.proposals.length === 0 && <div className="text-sm text-gray-500">No proposals for this status.</div>}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title="Oversight log">
|
|
<div className="space-y-2 max-h-96 overflow-auto">
|
|
{inbox.data.oversight.slice().reverse().map((o: any) => (
|
|
<div key={`${o.seq}-${o.hash}`} className="rounded border border-gray-200 p-3 text-sm">
|
|
<div className="font-medium text-gray-800">{o.event} · {o.proposal_id}</div>
|
|
<div className="text-xs text-gray-500">{o.actor} · {o.at} · hash {String(o.hash).slice(0, 12)}</div>
|
|
<div className="text-xs text-gray-600 mt-1">{o.reason}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|