Files
CASAN/packages/casan-control-panel/frontend/src/pages/Approvals.tsx
T
2026-07-08 19:07:35 +09:00

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>
</>
);
}