feat(CC-081): add API & Developer settings page (key management with scope selection)
This commit is contained in:
parent
c6e7fa35b7
commit
5ff18403bd
1 changed files with 144 additions and 0 deletions
144
src/app/dashboard/settings/api/page.tsx
Normal file
144
src/app/dashboard/settings/api/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface ApiKey { id: string; name: string; keyPreview: string; scopes: string[]; lastUsedAt: string | null; expiresAt: string | null; createdAt: string; }
|
||||
|
||||
const SCOPES = ['read:all', 'write:tasks', 'write:contacts', 'write:documents', 'write:observations', 'ai:query', 'export'];
|
||||
|
||||
export default function ApiDeveloperPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', scopes: [] as string[], expiresAt: '' });
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
|
||||
const { data: keys = [], isLoading } = useQuery({
|
||||
queryKey: ['api-keys', tenantId],
|
||||
queryFn: () => apiFetch<ApiKey[]>('/v1/api-keys', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/api-keys', { tenantId, method: 'POST', body: {
|
||||
name: form.name, scopes: form.scopes, expiresAt: form.expiresAt || null,
|
||||
}}),
|
||||
onSuccess: (data: { key?: string }) => {
|
||||
qc.invalidateQueries({ queryKey: ['api-keys', tenantId] });
|
||||
setNewKey(data?.key ?? null);
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', scopes: [], expiresAt: '' });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/v1/api-keys/${id}`, { tenantId, method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['api-keys', tenantId] }),
|
||||
});
|
||||
|
||||
function toggleScope(s: string) {
|
||||
setForm((p) => ({ ...p, scopes: p.scopes.includes(s) ? p.scopes.filter((x) => x !== s) : [...p.scopes, s] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">API & Developer</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">Chei API pentru acces programatic la CEO OS.</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowCreate(true); setNewKey(null); }}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Cheie nouă
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{newKey && (
|
||||
<div className="card p-4 border-signal-ok/30 bg-signal-ok/5 space-y-2">
|
||||
<p className="text-xs font-semibold text-signal-ok">✅ Cheia a fost creată. Copiaz-o acum — nu va mai fi afișată.</p>
|
||||
<code className="block rounded bg-muted px-3 py-2 text-xs font-mono text-ink break-all">{newKey}</code>
|
||||
<button onClick={() => { navigator.clipboard.writeText(newKey); }} className="text-xs text-primary hover:underline">
|
||||
Copiază în clipboard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4">
|
||||
<p className="text-sm font-semibold text-ink">Cheie API nouă</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Nume (ex: n8n-automation)" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input type="date" value={form.expiresAt} onChange={(e) => setForm((p) => ({ ...p, expiresAt: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-ink-faint mb-2">Scope-uri (principiul minimului privilegiu)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SCOPES.map((s) => (
|
||||
<button key={s} onClick={() => toggleScope(s)}
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-mono ${form.scopes.includes(s) ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate()} disabled={!form.name || form.scopes.length === 0 || createMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se creează…' : 'Creează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Endpoint reference */}
|
||||
<div className="card p-4 space-y-2">
|
||||
<p className="text-xs font-semibold text-ink-faint uppercase tracking-wider">Endpoint API</p>
|
||||
<code className="block text-xs font-mono text-ink">https://api.boardmind.dev/v1</code>
|
||||
<div className="text-[10px] text-ink-faint space-y-0.5 mt-2">
|
||||
<p>Header autentificare: <code className="bg-muted px-1 rounded">Authorization: Bearer YOUR_KEY</code></p>
|
||||
<p>Tenant ID: <code className="bg-muted px-1 rounded">X-Tenant-ID: {tenantId || 'YOUR_TENANT_ID'}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-4">Se încarcă…</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">🔑</p>
|
||||
<p className="text-sm text-ink-faint">Nicio cheie API. Creează prima cheie de mai sus.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{keys.map((key) => (
|
||||
<div key={key.id} className="flex items-center gap-4 p-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-ink">{key.name}</p>
|
||||
<code className="text-[10px] text-ink-faint font-mono">{key.keyPreview}…</code>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{(key.scopes ?? []).map((s) => (
|
||||
<span key={s} className="rounded bg-muted px-1 text-[8px] text-ink-faint font-mono">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-ink-faint shrink-0">
|
||||
{key.lastUsedAt ? <p>Folosit: {new Date(key.lastUsedAt).toLocaleDateString('ro-RO')}</p> : <p>Nefolosit</p>}
|
||||
{key.expiresAt && <p>Expiră: {new Date(key.expiresAt).toLocaleDateString('ro-RO')}</p>}
|
||||
</div>
|
||||
<button onClick={() => { if (confirm('Revocare cheie API?')) revokeMut.mutate(key.id); }}
|
||||
className="shrink-0 rounded border px-2 py-1 text-xs text-signal-danger border-signal-danger/30 hover:bg-signal-danger/10">
|
||||
Revocă
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue