shraga 0.1.112 → 0.1.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/defaults/mcps/README.md +6 -3
- package/defaults/scripts/agent-once.ts +2 -0
- package/defaults/security/policy.example.json +27 -0
- package/defaults/skills/mcp-server.md +15 -14
- package/defaults/skills/platform.md +3 -3
- package/defaults/system-prompt.md +1 -1
- package/dist/client/assets/index-CFxKc8dH.css +10 -0
- package/dist/client/assets/index-DwmlZAEF.js +2009 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/client/App.tsx +33 -8
- package/src/client/components/BackendStatusBanner.tsx +62 -0
- package/src/client/components/ConfigPanel.tsx +83 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/Sidebar.tsx +16 -1
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/components/owner/ApiKeysTab.tsx +87 -0
- package/src/client/components/owner/AuditTab.tsx +110 -0
- package/src/client/components/owner/BindingsTab.tsx +110 -0
- package/src/client/components/owner/BlocklistTab.tsx +96 -0
- package/src/client/components/owner/OwnerConsole.tsx +87 -0
- package/src/client/components/owner/PrincipalsTab.tsx +55 -0
- package/src/client/components/owner/RolesTab.tsx +132 -0
- package/src/client/components/owner/shared.tsx +84 -0
- package/src/client/hooks/useAuth.ts +11 -2
- package/src/client/hooks/useIsOwner.ts +24 -0
- package/src/client/hooks/useModules.ts +5 -1
- package/src/client/hooks/useOwner.ts +20 -0
- package/src/client/lib/api.ts +23 -7
- package/src/client/lib/backendHealth.ts +230 -0
- package/src/client/lib/debug.ts +48 -0
- package/src/client/lib/sessionApi.ts +24 -8
- package/src/client/lib/ws.ts +21 -13
- package/src/index.ts +10 -2
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/agent-config.ts +34 -0
- package/src/server/api-key-routes.ts +66 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +161 -104
- package/src/server/claude.ts +102 -26
- package/src/server/data-sync.ts +56 -6
- package/src/server/directives.ts +2 -4
- package/src/server/engine/claude-code.ts +78 -17
- package/src/server/engine/types.ts +7 -0
- package/src/server/hooks.ts +19 -0
- package/src/server/mcp-oauth.ts +24 -5
- package/src/server/mcp-server.ts +55 -25
- package/src/server/modules/routes.ts +2 -6
- package/src/server/notify-owners.ts +5 -17
- package/src/server/owners.ts +14 -0
- package/src/server/scheduler/builtins.ts +3 -1
- package/src/server/scheduler/runner.ts +3 -0
- package/src/server/security/audit.ts +505 -0
- package/src/server/security/enforce.ts +343 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +337 -0
- package/src/server/security/owner-only.ts +34 -0
- package/src/server/security/owner-routes.ts +285 -0
- package/src/server/security/policy.ts +416 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +226 -0
- package/src/server/sessions.ts +38 -0
- package/src/server/shraga-config.ts +13 -0
- package/src/server/slack/bot.ts +44 -11
- package/src/server/slack/context-cache.ts +40 -7
- package/src/server/webhook-lane/feature.ts +17 -6
- package/src/shared/models.ts +11 -0
- package/dist/client/assets/index-DIMte_k6.css +0 -10
- package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
|
@@ -4,6 +4,7 @@ import { Button } from './ui/button';
|
|
|
4
4
|
import { Input } from './ui/input';
|
|
5
5
|
import { Textarea } from './ui/textarea';
|
|
6
6
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
|
7
|
+
import { useIsOwner } from '@/hooks/useIsOwner';
|
|
7
8
|
|
|
8
9
|
type DefaultSkillEntry = string | { name: string; capped?: boolean | number };
|
|
9
10
|
|
|
@@ -43,8 +44,21 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
43
44
|
const [dirty, setDirty] = useState(false);
|
|
44
45
|
const [action, setAction] = useState<SidebarAction>(null);
|
|
45
46
|
const [actionName, setActionName] = useState('');
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
const isOwner = useIsOwner(getToken, open);
|
|
49
|
+
const canEdit = isOwner === true;
|
|
46
50
|
|
|
47
51
|
const isSelectedBuiltin = selected ? builtins.includes(selected) : false;
|
|
52
|
+
const readOnly = isSelectedBuiltin || !canEdit;
|
|
53
|
+
|
|
54
|
+
/** True if the mutation succeeded; otherwise surfaces the server's error and returns false. */
|
|
55
|
+
const ok = async (res: Response, what: string) => {
|
|
56
|
+
if (res.ok) { setError(null); return true; }
|
|
57
|
+
const msg = (await res.json().catch((e) => { console.warn(`[SkillsManager] ${what} error response not JSON`, res.status, e); return {}; })).error || `HTTP ${res.status}`;
|
|
58
|
+
console.warn(`[SkillsManager] ${what} failed`, msg);
|
|
59
|
+
setError(`${what} failed: ${msg}`);
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
48
62
|
|
|
49
63
|
const loadList = async () => {
|
|
50
64
|
const token = await getToken();
|
|
@@ -72,11 +86,11 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
72
86
|
};
|
|
73
87
|
|
|
74
88
|
const save = async () => {
|
|
75
|
-
if (!selected ||
|
|
89
|
+
if (!selected || readOnly) return;
|
|
76
90
|
const token = await getToken();
|
|
77
91
|
if (!token) return;
|
|
78
|
-
await apiFetch(`/api/skills/${selected}`, token, { method: 'PUT', body: JSON.stringify({ content }) });
|
|
79
|
-
setDirty(false);
|
|
92
|
+
const res = await apiFetch(`/api/skills/${selected}`, token, { method: 'PUT', body: JSON.stringify({ content }) });
|
|
93
|
+
if (await ok(res, 'Save')) setDirty(false);
|
|
80
94
|
};
|
|
81
95
|
|
|
82
96
|
const submitAction = async () => {
|
|
@@ -86,17 +100,18 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
86
100
|
if (!token) return;
|
|
87
101
|
|
|
88
102
|
if (action === 'create') {
|
|
89
|
-
await apiFetch(`/api/skills/${name}`, token, { method: 'PUT', body: JSON.stringify({ content: '' }) });
|
|
103
|
+
const res = await apiFetch(`/api/skills/${name}`, token, { method: 'PUT', body: JSON.stringify({ content: '' }) });
|
|
104
|
+
if (!await ok(res, 'Create')) return;
|
|
90
105
|
await loadList();
|
|
91
106
|
await loadSkill(name);
|
|
92
107
|
} else if (action === 'duplicate' && selected) {
|
|
93
108
|
const res = await apiFetch(`/api/skills/${selected}/duplicate`, token, { method: 'POST', body: JSON.stringify({ newName: name }) });
|
|
94
|
-
if (!res
|
|
109
|
+
if (!await ok(res, 'Duplicate')) return;
|
|
95
110
|
await loadList();
|
|
96
111
|
await loadSkill(name);
|
|
97
112
|
} else if (action === 'rename' && selected) {
|
|
98
113
|
const res = await apiFetch(`/api/skills/${selected}/rename`, token, { method: 'POST', body: JSON.stringify({ newName: name }) });
|
|
99
|
-
if (!res
|
|
114
|
+
if (!await ok(res, 'Rename')) return;
|
|
100
115
|
await loadList();
|
|
101
116
|
await loadSkill(name);
|
|
102
117
|
}
|
|
@@ -109,7 +124,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
109
124
|
const token = await getToken();
|
|
110
125
|
if (!token) return;
|
|
111
126
|
const res = await apiFetch(`/api/skills/${name}`, token, { method: 'DELETE' });
|
|
112
|
-
if (!res
|
|
127
|
+
if (!await ok(res, 'Delete')) return;
|
|
113
128
|
if (selected === name) { setSelected(null); setContent(''); }
|
|
114
129
|
await loadList();
|
|
115
130
|
};
|
|
@@ -120,8 +135,8 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
120
135
|
const next = isDefault(defaults, name)
|
|
121
136
|
? defaults.filter((d) => entryName(d) !== name)
|
|
122
137
|
: [...defaults, name];
|
|
123
|
-
await apiFetch('/api/skills-defaults', token, { method: 'PUT', body: JSON.stringify(next) });
|
|
124
|
-
setDefaults(next);
|
|
138
|
+
const res = await apiFetch('/api/skills-defaults', token, { method: 'PUT', body: JSON.stringify(next) });
|
|
139
|
+
if (await ok(res, 'Update defaults')) setDefaults(next);
|
|
125
140
|
};
|
|
126
141
|
|
|
127
142
|
|
|
@@ -163,9 +178,10 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
163
178
|
onClick={() => loadSkill(s)}
|
|
164
179
|
>
|
|
165
180
|
<button
|
|
166
|
-
className={`shrink-0 transition-colors ${isDefault(defaults, s) ? 'text-amber-500' : 'text-muted-foreground/30 hover:text-amber-400'}`}
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
className={`shrink-0 transition-colors ${isDefault(defaults, s) ? 'text-amber-500' : 'text-muted-foreground/30'} ${canEdit && !isDefault(defaults, s) ? 'hover:text-amber-400' : ''}`}
|
|
182
|
+
disabled={!canEdit}
|
|
183
|
+
onClick={(e) => { e.stopPropagation(); if (canEdit) toggleDefault(s); }}
|
|
184
|
+
title={!canEdit ? (isDefault(defaults, s) ? 'Default (only an owner can change)' : 'Only an owner can change defaults') : isDefault(defaults, s) ? 'Remove from defaults' : 'Set as default (always active)'}
|
|
169
185
|
>
|
|
170
186
|
<Star className={`w-3 h-3 ${isDefault(defaults, s) ? 'fill-current' : ''}`} />
|
|
171
187
|
</button>
|
|
@@ -173,7 +189,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
173
189
|
{builtins.includes(s) && (
|
|
174
190
|
<span title="Built-in (read-only)"><Lock className="w-2.5 h-2.5 text-muted-foreground/40 shrink-0" /></span>
|
|
175
191
|
)}
|
|
176
|
-
{!builtins.includes(s) && (
|
|
192
|
+
{!builtins.includes(s) && canEdit && (
|
|
177
193
|
<button
|
|
178
194
|
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-opacity shrink-0"
|
|
179
195
|
onClick={(e) => { e.stopPropagation(); remove(s); }}
|
|
@@ -200,11 +216,14 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
200
216
|
<Button size="icon" className="h-7 w-7 shrink-0" onClick={submitAction}><Check className="w-3 h-3" /></Button>
|
|
201
217
|
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={cancelAction}><X className="w-3 h-3" /></Button>
|
|
202
218
|
</div>
|
|
203
|
-
) : (
|
|
219
|
+
) : canEdit ? (
|
|
204
220
|
<Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={() => startAction('create')}>
|
|
205
221
|
<Plus className="w-3 h-3 mr-1" /> New skill
|
|
206
222
|
</Button>
|
|
223
|
+
) : (
|
|
224
|
+
<p className="text-[11px] text-muted-foreground text-center">{isOwner === false ? 'Read-only — only an owner can change skills' : ''}</p>
|
|
207
225
|
)}
|
|
226
|
+
{error && <p className="text-[11px] text-destructive mt-1 break-words">{error}</p>}
|
|
208
227
|
</div>
|
|
209
228
|
</div>
|
|
210
229
|
|
|
@@ -219,7 +238,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
219
238
|
<span>{selected}</span>
|
|
220
239
|
</div>
|
|
221
240
|
<div className="flex items-center gap-1.5">
|
|
222
|
-
{
|
|
241
|
+
{readOnly && (
|
|
223
242
|
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-medium flex items-center gap-0.5">
|
|
224
243
|
<Lock className="w-2.5 h-2.5" /> read-only
|
|
225
244
|
</span>
|
|
@@ -234,14 +253,16 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
234
253
|
</span>
|
|
235
254
|
</>
|
|
236
255
|
)}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
256
|
+
{canEdit && (
|
|
257
|
+
<Button
|
|
258
|
+
variant="ghost" size="sm" className="h-6 text-xs px-1.5"
|
|
259
|
+
onClick={() => startAction('duplicate')}
|
|
260
|
+
title="Duplicate"
|
|
261
|
+
>
|
|
262
|
+
<Copy className="w-3 h-3" />
|
|
263
|
+
</Button>
|
|
264
|
+
)}
|
|
265
|
+
{!readOnly && (
|
|
245
266
|
<Button
|
|
246
267
|
variant="ghost" size="sm" className="h-6 text-xs px-1.5"
|
|
247
268
|
onClick={() => startAction('rename')}
|
|
@@ -250,7 +271,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
250
271
|
<Pencil className="w-3 h-3" />
|
|
251
272
|
</Button>
|
|
252
273
|
)}
|
|
253
|
-
{dirty && !
|
|
274
|
+
{dirty && !readOnly && (
|
|
254
275
|
<Button size="sm" className="h-6 text-xs" onClick={save}>
|
|
255
276
|
<Check className="w-3 h-3 mr-1" /> Save
|
|
256
277
|
</Button>
|
|
@@ -259,9 +280,9 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
|
|
|
259
280
|
</div>
|
|
260
281
|
<Textarea
|
|
261
282
|
value={content}
|
|
262
|
-
onChange={(e) => { if (!
|
|
263
|
-
readOnly={
|
|
264
|
-
className={`flex-1 resize-none rounded-none border-0 font-mono text-xs focus-visible:ring-0 ${
|
|
283
|
+
onChange={(e) => { if (!readOnly) { setContent(e.target.value); setDirty(true); } }}
|
|
284
|
+
readOnly={readOnly}
|
|
285
|
+
className={`flex-1 resize-none rounded-none border-0 font-mono text-xs focus-visible:ring-0 ${readOnly ? 'opacity-70 cursor-default' : ''}`}
|
|
265
286
|
placeholder="Write the skill instructions here…"
|
|
266
287
|
/>
|
|
267
288
|
</>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { Copy, Loader2, Plus, Trash2 } from 'lucide-react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { ErrorBox, fmtTs, iconBtn, inputCls, selectCls, tdCls, thCls, useAction, type OwnerCall } from './shared';
|
|
6
|
+
|
|
7
|
+
interface Key { id: string; keyPreview: string; label: string; email: string; createdAt: number; role?: string; expiresAt?: number }
|
|
8
|
+
const EXPIRY: [string, number][] = [['never', 0], ['1 day', 1], ['7 days', 7], ['30 days', 30], ['90 days', 90]];
|
|
9
|
+
|
|
10
|
+
export function ApiKeysTab({ call, roles }: { call: OwnerCall; roles: string[] }) {
|
|
11
|
+
const [keys, setKeys] = useState<Key[] | null>(null);
|
|
12
|
+
const [form, setForm] = useState({ label: '', role: '', days: 0 });
|
|
13
|
+
const [created, setCreated] = useState<{ key: string; label: string } | null>(null);
|
|
14
|
+
const { busy, error, run } = useAction();
|
|
15
|
+
const load = useCallback(async () => setKeys((await call<{ keys: Key[] }>('/api-keys')).keys), [call]);
|
|
16
|
+
useEffect(() => { run('load', load); }, [load]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
17
|
+
|
|
18
|
+
const create = () => run('create', async () => {
|
|
19
|
+
const k = await call<{ key: string; label: string }>('/api-keys', 'POST', {
|
|
20
|
+
label: form.label || 'Unnamed', ...(form.role ? { role: form.role } : {}),
|
|
21
|
+
...(form.days ? { expiresAt: Date.now() + form.days * 86_400_000 } : {}),
|
|
22
|
+
});
|
|
23
|
+
setCreated(k);
|
|
24
|
+
setForm({ label: '', role: '', days: 0 });
|
|
25
|
+
await load();
|
|
26
|
+
});
|
|
27
|
+
const copy = (text: string) => navigator.clipboard.writeText(text).catch((e) => console.warn('[owner] clipboard write failed', e));
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="space-y-3">
|
|
31
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
32
|
+
<Input className={`${inputCls} w-48`} placeholder="label" value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} />
|
|
33
|
+
<select className={selectCls} value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} title="Role cap (never above the creator)">
|
|
34
|
+
<option value="">role: creator's (uncapped)</option>
|
|
35
|
+
{roles.map((r) => <option key={r} value={r}>role cap: {r}</option>)}
|
|
36
|
+
</select>
|
|
37
|
+
<select className={selectCls} value={form.days} onChange={(e) => setForm({ ...form, days: Number(e.target.value) })}>
|
|
38
|
+
{EXPIRY.map(([l, d]) => <option key={d} value={d}>expires: {l}</option>)}
|
|
39
|
+
</select>
|
|
40
|
+
<Button size="sm" className="h-7 text-xs" disabled={!!busy} onClick={create}>
|
|
41
|
+
{busy === 'create' ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Plus className="w-3 h-3 mr-1" />} Create key
|
|
42
|
+
</Button>
|
|
43
|
+
</div>
|
|
44
|
+
{created && (
|
|
45
|
+
<div className="border border-amber-500/50 bg-amber-500/10 rounded-lg p-2 space-y-1">
|
|
46
|
+
<p className="text-xs font-medium">Key "{created.label}" — copy it now, it won't be shown again:</p>
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<code className="text-xs font-mono break-all flex-1 select-all">{created.key}</code>
|
|
49
|
+
<button className={iconBtn} title="Copy" onClick={() => copy(created.key)}><Copy className="w-3.5 h-3.5" /></button>
|
|
50
|
+
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => setCreated(null)}>Done</Button>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
<ErrorBox error={error} />
|
|
55
|
+
<div className="border rounded-lg overflow-x-auto">
|
|
56
|
+
<table className="w-full text-xs">
|
|
57
|
+
<thead className="bg-muted/40"><tr>
|
|
58
|
+
<th className={thCls}>Label</th><th className={thCls}>Key</th><th className={thCls}>Creator</th><th className={thCls}>Role</th>
|
|
59
|
+
<th className={thCls}>Created</th><th className={thCls}>Expires</th><th className={thCls} />
|
|
60
|
+
</tr></thead>
|
|
61
|
+
<tbody className="divide-y">
|
|
62
|
+
{keys?.map((k) => {
|
|
63
|
+
const expired = k.expiresAt !== undefined && k.expiresAt <= Date.now();
|
|
64
|
+
return (
|
|
65
|
+
<tr key={k.id} className={expired ? 'opacity-50' : ''}>
|
|
66
|
+
<td className={tdCls}>{k.label}</td>
|
|
67
|
+
<td className={`${tdCls} font-mono`}>{k.keyPreview}</td>
|
|
68
|
+
<td className={tdCls}>{k.email}</td>
|
|
69
|
+
<td className={tdCls}>{k.role ?? <span className="text-muted-foreground">creator's</span>}</td>
|
|
70
|
+
<td className={`${tdCls} whitespace-nowrap`}>{fmtTs(k.createdAt)}</td>
|
|
71
|
+
<td className={`${tdCls} whitespace-nowrap`}>{k.expiresAt ? `${fmtTs(k.expiresAt)}${expired ? ' (expired)' : ''}` : 'never'}</td>
|
|
72
|
+
<td className={`${tdCls} text-right`}>
|
|
73
|
+
<button className={`${iconBtn} hover:text-destructive`} title="Revoke" disabled={!!busy}
|
|
74
|
+
onClick={() => { if (confirm(`Revoke key "${k.label}" (${k.keyPreview})?`)) run(`rm-${k.id}`, async () => { await call(`/api-keys/${k.id}`, 'DELETE'); await load(); }); }}>
|
|
75
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
76
|
+
</button>
|
|
77
|
+
</td>
|
|
78
|
+
</tr>
|
|
79
|
+
);
|
|
80
|
+
})}
|
|
81
|
+
{keys && !keys.length && <tr><td colSpan={7} className="text-center text-muted-foreground py-6">No API keys.</td></tr>}
|
|
82
|
+
</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { Ban, ExternalLink, KeyRound, Loader2, RefreshCw, ShieldAlert, ShieldCheck } from 'lucide-react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { ErrorBox, fmtTs, iconBtn, inputCls, principalActions, selectCls, tdCls, thCls, useAction, type OwnerCall } from './shared';
|
|
6
|
+
|
|
7
|
+
interface Rec { ts: string; type: string; principal?: string; role?: string; sessionId?: string; target?: string; reason?: string; hash: string }
|
|
8
|
+
interface Verify { ok: boolean; lines: number; brokenAt?: { file: string; line: number; reason: string }; planted?: string[] }
|
|
9
|
+
/** A failed verify: the real break (file:line, or just the file for a whole-file reason) plus any planted month entries. */
|
|
10
|
+
const verifyText = ({ brokenAt: b, planted }: Verify) => [
|
|
11
|
+
b && !(b.reason === 'not-regular-file' && planted?.length) ? `chain broken at ${b.file || 'audit dir'}${b.line ? `:${b.line}` : ''} (${b.reason})` : '',
|
|
12
|
+
planted?.length ? `planted entries (not regular files): ${planted.join(', ')}` : '',
|
|
13
|
+
].filter(Boolean).join(' · ') || 'chain verify failed';
|
|
14
|
+
const TYPES = ['auth.allow', 'auth.deny', 'role.resolve', 'turn.start', 'turn.end', 'tool.allow', 'tool.deny', 'guard.limit', 'guard.block',
|
|
15
|
+
'escalate', 'policy.change', 'policy.tamper', 'key.create', 'key.revoke', 'token.revoke', 'session.delete'];
|
|
16
|
+
const PAGE = 100;
|
|
17
|
+
|
|
18
|
+
export function AuditTab({ call, onOpenSession, onPolicyChange, ownerIds }: { call: OwnerCall; onOpenSession: (sessionId: string) => void | Promise<void>; onPolicyChange: () => Promise<void>; ownerIds: string[] }) {
|
|
19
|
+
const [f, setF] = useState({ principal: '', type: '', from: '', to: '' });
|
|
20
|
+
const [items, setItems] = useState<Rec[]>([]);
|
|
21
|
+
const [cursor, setCursor] = useState<string | undefined>();
|
|
22
|
+
const [verify, setVerify] = useState<Verify | null>(null);
|
|
23
|
+
const [deleted, setDeleted] = useState<ReadonlySet<string>>(new Set());
|
|
24
|
+
const { busy, error, run } = useAction();
|
|
25
|
+
const actions = principalActions(call, onPolicyChange, ownerIds);
|
|
26
|
+
/** Run a per-conversation action; a 404 means it's gone (removed by hand on the box): hide its row actions and say so. */
|
|
27
|
+
const onSession = (key: string, id: string, fn: () => void | Promise<void>) => run(key, async () => {
|
|
28
|
+
try { await fn(); } catch (e: any) {
|
|
29
|
+
if (e?.status !== 404) throw e;
|
|
30
|
+
setDeleted((s) => new Set(s).add(id));
|
|
31
|
+
throw new Error('Conversation no longer exists');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const query = (after?: string) => {
|
|
36
|
+
const q = new URLSearchParams({ limit: String(PAGE) });
|
|
37
|
+
if (f.principal.trim()) q.set('principal', f.principal.trim());
|
|
38
|
+
if (f.type) q.set('type', f.type);
|
|
39
|
+
if (f.from) q.set('from', new Date(f.from).toISOString());
|
|
40
|
+
if (f.to) q.set('to', new Date(f.to).toISOString());
|
|
41
|
+
if (after) q.set('cursor', after);
|
|
42
|
+
return call<{ items: Rec[]; nextCursor?: string }>(`/audit?${q}`);
|
|
43
|
+
};
|
|
44
|
+
const search = useCallback(() => run('load', async () => { const p = await query(); setItems(p.items); setCursor(p.nextCursor); }), [call, f]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
45
|
+
const more = () => run('more', async () => { const p = await query(cursor); setItems((x) => [...x, ...p.items]); setCursor(p.nextCursor); });
|
|
46
|
+
const check = () => run('verify', async () => setVerify(await call<Verify>('/audit/verify')));
|
|
47
|
+
useEffect(() => { search(); check(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-2">
|
|
51
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
52
|
+
<Input className={`${inputCls} w-56`} placeholder="principal (exact id)" value={f.principal} onChange={(e) => setF({ ...f, principal: e.target.value })} onKeyDown={(e) => e.key === 'Enter' && search()} />
|
|
53
|
+
<select className={selectCls} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })}>
|
|
54
|
+
<option value="">all types</option>
|
|
55
|
+
{TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
56
|
+
</select>
|
|
57
|
+
<label className="text-[10px] text-muted-foreground">from</label>
|
|
58
|
+
<input type="datetime-local" className={selectCls} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
|
|
59
|
+
<label className="text-[10px] text-muted-foreground">to</label>
|
|
60
|
+
<input type="datetime-local" className={selectCls} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
|
|
61
|
+
<Button size="sm" className="h-7 text-xs" disabled={!!busy} onClick={search}>
|
|
62
|
+
{busy === 'load' ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <RefreshCw className="w-3 h-3 mr-1" />} Search
|
|
63
|
+
</Button>
|
|
64
|
+
<div className="flex-1" />
|
|
65
|
+
{verify && (
|
|
66
|
+
<button onClick={check} title="Re-verify the hash chain"
|
|
67
|
+
className={`flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border ${verify.ok ? 'border-green-500/50 text-green-600 dark:text-green-400' : 'border-destructive text-destructive'}`}>
|
|
68
|
+
{verify.ok ? <ShieldCheck className="w-3.5 h-3.5" /> : <ShieldAlert className="w-3.5 h-3.5" />}
|
|
69
|
+
{verify.ok ? `chain verified · ${verify.lines} lines` : verifyText(verify)}
|
|
70
|
+
</button>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
<ErrorBox error={error} />
|
|
74
|
+
<div className="border rounded-lg overflow-x-auto">
|
|
75
|
+
<table className="w-full text-xs">
|
|
76
|
+
<thead className="bg-muted/40"><tr>
|
|
77
|
+
<th className={thCls}>Time</th><th className={thCls}>Type</th><th className={thCls}>Principal</th><th className={thCls}>Role</th>
|
|
78
|
+
<th className={thCls}>Target / reason</th><th className={thCls} />
|
|
79
|
+
</tr></thead>
|
|
80
|
+
<tbody className="divide-y">
|
|
81
|
+
{items.map((r) => (
|
|
82
|
+
<tr key={r.hash}>
|
|
83
|
+
<td className={`${tdCls} whitespace-nowrap`} title={r.ts}>{fmtTs(r.ts)}</td>
|
|
84
|
+
<td className={`${tdCls} font-mono whitespace-nowrap ${/deny|block|tamper/.test(r.type) ? 'text-destructive' : ''}`}>{r.type}</td>
|
|
85
|
+
<td className={`${tdCls} font-mono break-all`}>
|
|
86
|
+
{r.principal ? <button className="hover:underline text-left" title="Filter by this principal" onClick={() => setF({ ...f, principal: r.principal! })}>{r.principal}</button> : '—'}
|
|
87
|
+
</td>
|
|
88
|
+
<td className={tdCls}>{r.role ?? '—'}</td>
|
|
89
|
+
<td className={`${tdCls} break-all`}>{[r.target, r.reason].filter(Boolean).join(' · ') || '—'}</td>
|
|
90
|
+
<td className={`${tdCls} whitespace-nowrap text-right`}>
|
|
91
|
+
{r.sessionId && !deleted.has(r.sessionId) && <button className={`${iconBtn} mr-2`} title="Open session" disabled={!!busy} onClick={() => onSession('open', r.sessionId!, () => onOpenSession(r.sessionId!))}><ExternalLink className="w-3.5 h-3.5" /></button>}
|
|
92
|
+
{r.principal && actions.canRevoke(r.principal) && (
|
|
93
|
+
<button className={`${iconBtn} mr-2`} title="Revoke tokens" disabled={!!busy} onClick={() => run('rv', () => actions.revoke(r.principal!))}><KeyRound className="w-3.5 h-3.5" /></button>
|
|
94
|
+
)}
|
|
95
|
+
{r.principal && actions.canBlock(r.principal) && <button className={`${iconBtn} hover:text-destructive`} title="Block principal" disabled={!!busy} onClick={() => run('bl', () => actions.block(r.principal!))}><Ban className="w-3.5 h-3.5" /></button>}
|
|
96
|
+
</td>
|
|
97
|
+
</tr>
|
|
98
|
+
))}
|
|
99
|
+
{!items.length && busy !== 'load' && <tr><td colSpan={6} className="text-center text-muted-foreground py-6">No audit records match.</td></tr>}
|
|
100
|
+
</tbody>
|
|
101
|
+
</table>
|
|
102
|
+
</div>
|
|
103
|
+
{cursor && (
|
|
104
|
+
<Button size="sm" variant="outline" className="h-7 text-xs" disabled={!!busy} onClick={more}>
|
|
105
|
+
{busy === 'more' && <Loader2 className="w-3 h-3 mr-1 animate-spin" />} Load older
|
|
106
|
+
</Button>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ArrowDown, ArrowUp, FlaskConical, Loader2, Plus, Trash2 } from 'lucide-react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { usePolicyDraft } from './RolesTab';
|
|
6
|
+
import { CsvInput, ErrorBox, iconBtn, inputCls, KINDS, selectCls, useAction, type Match, type OwnerCall, type Policy, type PolicyDoc, type SavePolicy } from './shared';
|
|
7
|
+
|
|
8
|
+
const KNOWN = new Set(['kind', 'id', 'emailIn', 'domain', 'verified']);
|
|
9
|
+
|
|
10
|
+
export function BindingsTab({ doc, save, call, onDirty }: { doc: PolicyDoc; save: SavePolicy; call: OwnerCall; onDirty: (dirty: boolean) => void }) {
|
|
11
|
+
const { draft, setDraft, error, saveBar } = usePolicyDraft(doc, save, onDirty);
|
|
12
|
+
const roles = Object.keys(draft.roles).filter((r) => r !== 'owner');
|
|
13
|
+
|
|
14
|
+
const update = (fn: (b: Policy['bindings']) => Policy['bindings']) => setDraft((d) => ({ ...d, bindings: fn([...d.bindings]) }));
|
|
15
|
+
/** Empty values are dropped from the match (absent = "any"); unknown match keys are preserved untouched. */
|
|
16
|
+
const setMatch = (i: number, key: string, v: unknown) => update((b) => {
|
|
17
|
+
const m: Match = { ...b[i].match };
|
|
18
|
+
if (v === undefined || v === '' || (Array.isArray(v) && !v.length)) delete m[key]; else m[key] = v;
|
|
19
|
+
b[i] = { ...b[i], match: m };
|
|
20
|
+
return b;
|
|
21
|
+
});
|
|
22
|
+
const move = (i: number, to: number) => update((b) => { const [x] = b.splice(i, 1); b.splice(to, 0, x); return b; });
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="space-y-4">
|
|
26
|
+
<ErrorBox error={error} />
|
|
27
|
+
<p className="text-[11px] text-muted-foreground">Ordered — the first matching binding decides the role; no match → default role <b>{draft.default}</b>.</p>
|
|
28
|
+
<div className="space-y-2">
|
|
29
|
+
{draft.bindings.map((b, i) => {
|
|
30
|
+
const extra = Object.entries(b.match).filter(([k]) => !KNOWN.has(k));
|
|
31
|
+
return (
|
|
32
|
+
<div key={i} className="border rounded-lg p-2 space-y-1.5">
|
|
33
|
+
<div className="flex items-center gap-2">
|
|
34
|
+
<span className="text-[10px] font-mono text-muted-foreground w-5">#{i + 1}</span>
|
|
35
|
+
<label className="text-[10px] text-muted-foreground">role</label>
|
|
36
|
+
<select className={selectCls} value={b.role} onChange={(e) => update((x) => { x[i] = { ...x[i], role: e.target.value }; return x; })}>
|
|
37
|
+
{!roles.includes(b.role) && <option value={b.role}>{b.role} (invalid)</option>}
|
|
38
|
+
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
|
|
39
|
+
</select>
|
|
40
|
+
<div className="flex-1" />
|
|
41
|
+
<button className={iconBtn} title="Move up" disabled={i === 0} onClick={() => move(i, i - 1)}><ArrowUp className="w-3.5 h-3.5" /></button>
|
|
42
|
+
<button className={iconBtn} title="Move down" disabled={i === draft.bindings.length - 1} onClick={() => move(i, i + 1)}><ArrowDown className="w-3.5 h-3.5" /></button>
|
|
43
|
+
<button className={`${iconBtn} hover:text-destructive`} title="Remove" onClick={() => update((x) => x.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></button>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
46
|
+
<select className={selectCls} title="kind" value={b.match.kind ?? ''} onChange={(e) => setMatch(i, 'kind', e.target.value)}>
|
|
47
|
+
<option value="">any kind</option>
|
|
48
|
+
{KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
|
|
49
|
+
</select>
|
|
50
|
+
<select className={selectCls} title="verified" value={b.match.verified === undefined ? '' : String(b.match.verified)} onChange={(e) => setMatch(i, 'verified', e.target.value === '' ? undefined : e.target.value === 'true')}>
|
|
51
|
+
<option value="">verified: any</option><option value="true">verified</option><option value="false">unverified</option>
|
|
52
|
+
</select>
|
|
53
|
+
<Input className={`${inputCls} w-40`} placeholder="domain" value={b.match.domain ?? ''} onChange={(e) => setMatch(i, 'domain', e.target.value.trim())} />
|
|
54
|
+
<Input className={`${inputCls} w-48`} placeholder="id (e.g. slack:U123)" value={b.match.id ?? ''} onChange={(e) => setMatch(i, 'id', e.target.value.trim())} />
|
|
55
|
+
<div className="flex-1 min-w-48"><CsvInput value={b.match.emailIn} onChange={(v) => setMatch(i, 'emailIn', v)} placeholder="emails (comma-separated)" /></div>
|
|
56
|
+
</div>
|
|
57
|
+
{extra.length > 0 && <p className="text-[10px] font-mono text-muted-foreground">also matches: {JSON.stringify(Object.fromEntries(extra))}</p>}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
<Button size="sm" variant="outline" className="h-7 text-xs" disabled={!roles.length}
|
|
62
|
+
onClick={() => update((b) => [...b, { match: { kind: 'user' }, role: roles.includes('guest') ? 'guest' : roles[roles.length - 1] }])}>
|
|
63
|
+
<Plus className="w-3 h-3 mr-1" /> Add binding
|
|
64
|
+
</Button>
|
|
65
|
+
</div>
|
|
66
|
+
{saveBar}
|
|
67
|
+
<TestPrincipal call={call} />
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface TestResult { principal: string; role: string; rank: number; profile: string; blocked: { reason?: string } | null }
|
|
73
|
+
|
|
74
|
+
function TestPrincipal({ call }: { call: OwnerCall }) {
|
|
75
|
+
const [d, setD] = useState({ kind: 'user', email: '', id: '', verified: true, lane: '', role: '' });
|
|
76
|
+
const [result, setResult] = useState<TestResult | null>(null);
|
|
77
|
+
const { busy, error, run } = useAction();
|
|
78
|
+
const set = (patch: Partial<typeof d>) => { setD((x) => ({ ...x, ...patch })); setResult(null); };
|
|
79
|
+
const test = () => run('test', async () => {
|
|
80
|
+
setResult(await call<TestResult>('/policy/test', 'POST', {
|
|
81
|
+
kind: d.kind, verified: d.verified, ...(d.email ? { email: d.email } : {}), ...(d.id ? { id: d.id } : {}),
|
|
82
|
+
...(d.lane ? { lane: d.lane } : {}), ...(d.role ? { role: d.role } : {}),
|
|
83
|
+
}));
|
|
84
|
+
});
|
|
85
|
+
return (
|
|
86
|
+
<section className="border rounded-lg p-3 space-y-2 bg-muted/20">
|
|
87
|
+
<h3 className="text-xs font-medium flex items-center gap-1.5"><FlaskConical className="w-3.5 h-3.5" /> Test a principal <span className="text-muted-foreground font-normal">(against the SAVED policy)</span></h3>
|
|
88
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
89
|
+
<select className={selectCls} value={d.kind} onChange={(e) => set({ kind: e.target.value })}>
|
|
90
|
+
{KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
|
|
91
|
+
</select>
|
|
92
|
+
<Input className={`${inputCls} w-52`} placeholder={d.kind === 'apikey' ? 'creator email' : 'email'} value={d.email} onChange={(e) => set({ email: e.target.value.trim() })} />
|
|
93
|
+
<Input className={`${inputCls} w-32`} placeholder="id (optional)" value={d.id} onChange={(e) => set({ id: e.target.value.trim() })} />
|
|
94
|
+
{d.kind === 'internal' && <Input className={`${inputCls} w-28`} placeholder="lane" value={d.lane} onChange={(e) => set({ lane: e.target.value.trim() })} />}
|
|
95
|
+
{d.kind === 'apikey' && <Input className={`${inputCls} w-28`} placeholder="key role cap" value={d.role} onChange={(e) => set({ role: e.target.value.trim() })} />}
|
|
96
|
+
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={d.verified} onChange={(e) => set({ verified: e.target.checked })} /> verified</label>
|
|
97
|
+
<Button size="sm" className="h-7 text-xs" disabled={!!busy} onClick={test}>
|
|
98
|
+
{busy && <Loader2 className="w-3 h-3 mr-1 animate-spin" />} Resolve
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
<ErrorBox error={error} />
|
|
102
|
+
{result && (
|
|
103
|
+
<p className="text-xs font-mono">
|
|
104
|
+
{result.principal} → role <b>{result.role}</b> · rank {result.rank} · profile <b>{result.profile}</b>
|
|
105
|
+
{result.blocked && <span className="text-destructive"> · BLOCKED{result.blocked.reason ? ` (${result.blocked.reason})` : ''}</span>}
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
108
|
+
</section>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { ErrorBox, fmtTs, iconBtn, inputCls, matchLabel, selectCls, tdCls, thCls, useAction, type Match, type OwnerCall } from './shared';
|
|
6
|
+
|
|
7
|
+
interface Blocks {
|
|
8
|
+
manual: { index: number; match: Match; until: number | null; reason?: string }[];
|
|
9
|
+
auto: { key: string; until: number; reason: string; at: number }[];
|
|
10
|
+
}
|
|
11
|
+
const TTL: [string, number][] = [['permanent', 0], ['1 hour', 3600], ['24 hours', 86_400], ['7 days', 604_800], ['30 days', 2_592_000]];
|
|
12
|
+
|
|
13
|
+
export function BlocklistTab({ call, onPolicyChange }: { call: OwnerCall; onPolicyChange: () => Promise<void> }) {
|
|
14
|
+
const [blocks, setBlocks] = useState<Blocks | null>(null);
|
|
15
|
+
const [form, setForm] = useState({ field: 'id', value: '', ttl: 0, reason: '' });
|
|
16
|
+
const { busy, error, run } = useAction();
|
|
17
|
+
const load = useCallback(async () => setBlocks(await call<Blocks>('/blocks')), [call]);
|
|
18
|
+
useEffect(() => { run('load', load); }, [load]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
19
|
+
|
|
20
|
+
const add = () => run('add', async () => {
|
|
21
|
+
const v = form.value.trim();
|
|
22
|
+
const match: Match = form.field === 'emailIn' ? { emailIn: [v] } : { [form.field]: v };
|
|
23
|
+
await call('/blocks', 'POST', { match, until: form.ttl ? Math.floor(Date.now() / 1000) + form.ttl : null, reason: form.reason });
|
|
24
|
+
setForm({ ...form, value: '', reason: '' });
|
|
25
|
+
await Promise.all([load(), onPolicyChange()]);
|
|
26
|
+
});
|
|
27
|
+
const remove = (body: object) => run('rm', async () => { await call('/blocks', 'DELETE', body); await Promise.all([load(), onPolicyChange()]); });
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="space-y-4">
|
|
31
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
32
|
+
<select className={selectCls} value={form.field} onChange={(e) => setForm({ ...form, field: e.target.value })}>
|
|
33
|
+
<option value="id">principal id</option><option value="emailIn">email</option><option value="domain">domain</option>
|
|
34
|
+
</select>
|
|
35
|
+
<Input className={`${inputCls} w-56`} placeholder={form.field === 'id' ? 'user:x@y.com / slack:U123' : form.field === 'domain' ? 'spam.tld' : 'x@y.com'} value={form.value} onChange={(e) => setForm({ ...form, value: e.target.value })} />
|
|
36
|
+
<select className={selectCls} value={form.ttl} onChange={(e) => setForm({ ...form, ttl: Number(e.target.value) })}>
|
|
37
|
+
{TTL.map(([l, s]) => <option key={s} value={s}>{l}</option>)}
|
|
38
|
+
</select>
|
|
39
|
+
<Input className={`${inputCls} w-40`} placeholder="reason" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} />
|
|
40
|
+
<Button size="sm" className="h-7 text-xs" disabled={!!busy || !form.value.trim()} onClick={add}>
|
|
41
|
+
{busy === 'add' ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Plus className="w-3 h-3 mr-1" />} Block
|
|
42
|
+
</Button>
|
|
43
|
+
<button className={iconBtn} title="Refresh" onClick={() => run('load', load)}><RefreshCw className={`w-3.5 h-3.5 ${busy === 'load' ? 'animate-spin' : ''}`} /></button>
|
|
44
|
+
</div>
|
|
45
|
+
<ErrorBox error={error} />
|
|
46
|
+
|
|
47
|
+
<section className="space-y-1">
|
|
48
|
+
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Manual (policy)</h3>
|
|
49
|
+
<div className="border rounded-lg overflow-x-auto">
|
|
50
|
+
<table className="w-full text-xs">
|
|
51
|
+
<thead className="bg-muted/40"><tr><th className={thCls}>Match</th><th className={thCls}>Until</th><th className={thCls}>Reason</th><th className={thCls} /></tr></thead>
|
|
52
|
+
<tbody className="divide-y">
|
|
53
|
+
{blocks?.manual.map((b) => {
|
|
54
|
+
const expired = b.until !== null && b.until * 1000 <= Date.now();
|
|
55
|
+
return (
|
|
56
|
+
<tr key={b.index} className={expired ? 'opacity-50' : ''}>
|
|
57
|
+
<td className={`${tdCls} font-mono break-all`}>{matchLabel(b.match)}</td>
|
|
58
|
+
<td className={`${tdCls} whitespace-nowrap`}>{b.until === null ? 'permanent' : `${fmtTs(b.until * 1000)}${expired ? ' (expired)' : ''}`}</td>
|
|
59
|
+
<td className={tdCls}>{b.reason || '—'}</td>
|
|
60
|
+
<td className={`${tdCls} text-right`}>
|
|
61
|
+
<button className={`${iconBtn} hover:text-destructive`} title="Remove" disabled={!!busy} onClick={() => remove({ source: 'manual', index: b.index, match: b.match })}><Trash2 className="w-3.5 h-3.5" /></button>
|
|
62
|
+
</td>
|
|
63
|
+
</tr>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
{blocks && !blocks.manual.length && <tr><td colSpan={4} className="text-center text-muted-foreground py-4">No manual blocks.</td></tr>}
|
|
67
|
+
</tbody>
|
|
68
|
+
</table>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<section className="space-y-1">
|
|
73
|
+
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Automatic (guard)</h3>
|
|
74
|
+
<div className="border rounded-lg overflow-x-auto">
|
|
75
|
+
<table className="w-full text-xs">
|
|
76
|
+
<thead className="bg-muted/40"><tr><th className={thCls}>Key</th><th className={thCls}>Since</th><th className={thCls}>Until</th><th className={thCls}>Reason</th><th className={thCls} /></tr></thead>
|
|
77
|
+
<tbody className="divide-y">
|
|
78
|
+
{blocks?.auto.map((b) => (
|
|
79
|
+
<tr key={b.key}>
|
|
80
|
+
<td className={`${tdCls} font-mono break-all`}>{b.key}</td>
|
|
81
|
+
<td className={`${tdCls} whitespace-nowrap`}>{fmtTs(b.at)}</td>
|
|
82
|
+
<td className={`${tdCls} whitespace-nowrap`}>{fmtTs(b.until)}</td>
|
|
83
|
+
<td className={tdCls}>{b.reason}</td>
|
|
84
|
+
<td className={`${tdCls} text-right`}>
|
|
85
|
+
<button className={`${iconBtn} hover:text-destructive`} title="Clear" disabled={!!busy} onClick={() => remove({ source: 'auto', key: b.key })}><Trash2 className="w-3.5 h-3.5" /></button>
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
))}
|
|
89
|
+
{blocks && !blocks.auto.length && <tr><td colSpan={5} className="text-center text-muted-foreground py-4">No active auto-blocks.</td></tr>}
|
|
90
|
+
</tbody>
|
|
91
|
+
</table>
|
|
92
|
+
</div>
|
|
93
|
+
</section>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|