f325299d-c1a0-4376-98ae-ddb747d23da5
This commit is contained in:
1 parent
9c58e29472
commit
be34f88822
22 files changed
+1361
-1
No files matched your search
@@ -20,6 +20,7 @@ import { ExpensesView } from './views/expenses'
|
||||
import { FinanceView } from './views/finance'
|
||||
import { AccountingView } from './views/accounting'
|
||||
import { AuditView } from './views/audit'
|
||||
import { ApiView } from './views/api'
|
||||
import { SettingsView } from './views/settings'
|
||||
import { SetupWizardView } from './views/setup-wizard'
|
||||
|
||||
@@ -60,6 +61,7 @@ export function AppShell() {
|
||||
{view === 'finance' && <FinanceView />}
|
||||
{view === 'accounting' && <AccountingView />}
|
||||
{view === 'audit' && <AuditView />}
|
||||
{view === 'api' && <ApiView />}
|
||||
{view === 'settings' && <SettingsView />}
|
||||
{view === 'setup' && <SetupWizardView />}
|
||||
</main>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
BarChart3,
|
||||
Rocket,
|
||||
BookOpen,
|
||||
Webhook,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
|
||||
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
||||
{ id: 'api', label: 'API & Webhooks', icon: Webhook, hint: 'Tokens, Scopes, HMAC' },
|
||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
||||
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
||||
]
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BarChart3,
|
||||
Rocket,
|
||||
BookOpen,
|
||||
Webhook,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -66,6 +67,7 @@ const NAV: {
|
||||
group: 'System',
|
||||
items: [
|
||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' },
|
||||
{ id: 'api', label: 'API & Webhooks', icon: Webhook, permission: 'admin' },
|
||||
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, permission: 'admin' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
||||
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
||||
api: { title: 'API & Webhooks', crumb: 'System / API' },
|
||||
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
||||
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Webhook,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
ExternalLink,
|
||||
Ban,
|
||||
Activity,
|
||||
Code,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { formatRelative } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface ApiToken {
|
||||
id: string
|
||||
name: string
|
||||
tokenPrefix: string
|
||||
scopes: string[]
|
||||
lastUsedAt: string | null
|
||||
expiresAt: string | null
|
||||
revokedAt: string | null
|
||||
createdAt: string
|
||||
active: boolean
|
||||
}
|
||||
interface Webhook {
|
||||
id: string
|
||||
url: string
|
||||
events: string[]
|
||||
active: boolean
|
||||
sentCount: number
|
||||
failCount: number
|
||||
lastSentAt: string | null
|
||||
lastStatus: number | null
|
||||
createdAt: string
|
||||
hasSecret: boolean
|
||||
}
|
||||
interface ApiData {
|
||||
tokens: ApiToken[]
|
||||
webhooks: Webhook[]
|
||||
availableScopes: string[]
|
||||
knownEvents: string[]
|
||||
}
|
||||
|
||||
export function ApiView() {
|
||||
const { data, loading, refetch } = useFetch<ApiData>('/api/aos/api-tokens')
|
||||
const { data: webhookData, refetch: refetchWebhooks } = useFetch<ApiData>('/api/aos/webhooks')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [newToken, setNewToken] = useState<string | null>(null)
|
||||
const [tokenName, setTokenName] = useState('')
|
||||
const [tokenScopes, setTokenScopes] = useState<string[]>([])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [whCreateOpen, setWhCreateOpen] = useState(false)
|
||||
const [whUrl, setWhUrl] = useState('')
|
||||
const [whEvents, setWhEvents] = useState<string[]>([])
|
||||
const [whCreating, setWhCreating] = useState(false)
|
||||
const [newWhSecret, setNewWhSecret] = useState<string | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const allScopes = data?.availableScopes ?? []
|
||||
const knownEvents = webhookData?.knownEvents ?? []
|
||||
const tokens = data?.tokens ?? []
|
||||
const webhooks = webhookData?.webhooks ?? []
|
||||
|
||||
const createToken = async () => {
|
||||
if (!tokenName.trim() || tokenScopes.length === 0) return
|
||||
setCreating(true)
|
||||
const t = toast.loading('Token wird generiert (256-bit, SHA-256 gehasht)…')
|
||||
try {
|
||||
const r = await fetch('/api/aos/api-tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: tokenName, scopes: tokenScopes }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'create failed')
|
||||
setNewToken(res.token)
|
||||
toast.success('Token erstellt — nur einmal sichtbar!', { id: t })
|
||||
refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const revokeToken = async (id: string) => {
|
||||
const t = toast.loading('Token wird widerrufen…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/api-tokens/${id}/revoke`, { method: 'PATCH' })
|
||||
if (!r.ok) throw new Error('revoke failed')
|
||||
toast.success('Token widerrufen', { id: t })
|
||||
refetch()
|
||||
} catch {
|
||||
toast.error('Widerruf fehlgeschlagen', { id: t })
|
||||
}
|
||||
}
|
||||
|
||||
const createWebhook = async () => {
|
||||
if (!whUrl.trim() || whEvents.length === 0) return
|
||||
setWhCreating(true)
|
||||
const t = toast.loading('Webhook wird registriert (HMAC-Secret generiert)…')
|
||||
try {
|
||||
const r = await fetch('/api/aos/webhooks', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ url: whUrl, events: whEvents }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'create failed')
|
||||
setNewWhSecret(res.secret)
|
||||
toast.success('Webhook registriert — Secret einmalig sichtbar', { id: t })
|
||||
refetchWebhooks()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Registrierung fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setWhCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testWebhooks = async () => {
|
||||
setTesting(true)
|
||||
const t = toast.loading('Test-Webhook wird versendet (realer HTTP POST + HMAC)…')
|
||||
try {
|
||||
const r = await fetch('/api/v1/webhooks/test', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ event: 'test.event', payload: { source: 'AOS UI', timestamp: new Date().toISOString() } }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'test failed')
|
||||
toast.success(`${res.delivered} Webhook(s) ausgeliefert`, { id: t })
|
||||
refetchWebhooks()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Test fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToken = () => {
|
||||
if (newToken) {
|
||||
navigator.clipboard.writeText(newToken)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="API & Webhooks"
|
||||
description="Public REST API mit Scoped API-Tokens (SHA-256 gehasht). Webhooks mit HMAC-SHA256-Signatur. OpenAPI-Spec auto-generiert. Für externe Agenten (ZeroClaw) und Integrationen."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Aktive Tokens</p>
|
||||
<KeyRound className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{tokens.filter((t) => t.active).length}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Webhooks</p>
|
||||
<Webhook className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{webhooks.filter((w) => w.active).length}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Ausgeliefert</p>
|
||||
<Send className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{webhooks.reduce((s, w) => s + w.sentCount, 0)}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Fehlgeschlagen</p>
|
||||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{webhooks.reduce((s, w) => s + w.failCount, 0)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="tokens">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tokens">API-Tokens</TabsTrigger>
|
||||
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
|
||||
<TabsTrigger value="docs">Dokumentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tokens tab */}
|
||||
<TabsContent value="tokens" className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tokens werden SHA-256 gehasht gespeichert. Das Plaintext-Token ist nur einmal bei Erstellung sichtbar.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => { setCreateOpen(true); setNewToken(null); setTokenName(''); setTokenScopes([]) }}>
|
||||
<Plus className="size-4" />Token erstellen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Präfix</th>
|
||||
<th className="px-4 py-2 font-medium">Scopes</th>
|
||||
<th className="px-4 py-2 font-medium">Zuletzt verwendet</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{tokens.map((t) => (
|
||||
<tr key={t.id} className="transition hover:bg-muted/40">
|
||||
<td className="px-4 py-2.5 text-xs font-medium">{t.name}</td>
|
||||
<td className="px-4 py-2.5"><code className="font-mono text-[11px] text-muted-foreground">{t.tokenPrefix}…</code></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.scopes.map((s) => <Badge key={s} variant="outline" className="text-[9px] font-mono">{s}</Badge>)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-[11px] text-muted-foreground">{t.lastUsedAt ? formatRelative(t.lastUsedAt) : 'nie'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{t.revokedAt ? (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">Widerrufen</Badge>
|
||||
) : t.active ? (
|
||||
<Badge variant="outline" className="text-[10px] border-foreground/40">Aktiv</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">Abgelaufen</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{t.active && (
|
||||
<Button size="sm" variant="ghost" className="h-7 gap-1 text-[11px] text-muted-foreground hover:text-foreground" onClick={() => revokeToken(t.id)}>
|
||||
<Ban className="size-3" />Widerrufen
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Webhooks tab */}
|
||||
<TabsContent value="webhooks" className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
POST mit HMAC-SHA256-Signatur (<code className="font-mono">x-aos-signature</code> Header).
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={testWebhooks} disabled={testing}>
|
||||
{testing ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
Test senden
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setWhCreateOpen(true); setNewWhSecret(null); setWhUrl(''); setWhEvents([]) }}>
|
||||
<Plus className="size-4" />Webhook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{webhooks.map((w) => (
|
||||
<Card key={w.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="truncate text-xs font-mono">{w.url}</code>
|
||||
{w.lastStatus && (
|
||||
<Badge variant="outline" className={cn('text-[10px]', w.lastStatus >= 200 && w.lastStatus < 300 ? 'border-foreground/40' : 'border-foreground/60')}>
|
||||
{w.lastStatus}
|
||||
</Badge>
|
||||
)}
|
||||
{!w.active && <Badge variant="outline" className="text-[10px] text-muted-foreground">Inaktiv</Badge>}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{w.events.map((e) => <Badge key={e} variant="secondary" className="text-[9px] font-mono">{e}</Badge>)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Send className="size-2.5" />{w.sentCount} gesendet</span>
|
||||
{w.failCount > 0 && <span className="flex items-center gap-1"><Activity className="size-2.5" />{w.failCount} fehl</span>}
|
||||
{w.lastSentAt && <span>· {formatRelative(w.lastSentAt)}</span>}
|
||||
{w.hasSecret && <span className="flex items-center gap-1"><ShieldCheck className="size-2.5" />Secret gesetzt</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{webhooks.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">Keine Webhooks registriert.</p>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Docs tab */}
|
||||
<TabsContent value="docs" className="mt-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Code className="size-3.5 text-muted-foreground" />
|
||||
OpenAPI-Spezifikation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
Die API-Spezifikation wird automatisch generiert und unter <code className="rounded bg-muted px-1 font-mono">/api/v1/openapi.json</code> bereitgestellt.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">Verfügbare Endpoints:</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`GET /api/v1/companies # crm.company:read
|
||||
GET /api/v1/invoices # finance.invoice:read
|
||||
GET /api/v1/licenses # licenses.license:read
|
||||
POST /api/v1/webhooks/test # webhooks.test
|
||||
GET /api/v1/openapi.json # (öffentlich, kein Token)`}</pre>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">Authentifizierung:</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`curl -H "Authorization: Bearer aos_<64hex>" \\
|
||||
https://aos.nordlicht.digital/api/v1/companies`}</pre>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">Webhook-Signatur verifizieren (Node.js):</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`import { createHmac } from 'crypto'
|
||||
const sig = createHmac('sha256', WEBHOOK_SECRET)
|
||||
.update(\`\${timestamp}.\${body}\`)
|
||||
.digest('hex')
|
||||
// vergleiche mit x-aos-signature Header: "sha256=<sig>"`}</pre>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Sicherheit</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
<li>• Tokens werden SHA-256 gehasht gespeichert (compromised DB → keine nutzbaren Tokens)</li>
|
||||
<li>• Scopes sind <code className="font-mono">resource:action</code> (kein Wildcard-Schreiben)</li>
|
||||
<li>• Webhook-Secrets werden einmalig generiert und zur Verifizierung benötigt</li>
|
||||
<li>• Alle API-Zugriffe werden im Audit-Log protokolliert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Verfügbare Scopes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
{allScopes.map((s) => (
|
||||
<div key={s} className="flex items-center justify-between rounded-md border border-border px-3 py-1.5">
|
||||
<code className="font-mono text-[11px]">{s}</code>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{s.startsWith('crm.') && 'Lese-Zugriff auf Firmen/Kontakte'}
|
||||
{s.startsWith('finance.') && 'Lese-Zugriff auf Rechnungen'}
|
||||
{s.startsWith('licenses.') && 'Lese-Zugriff auf Lizenzen'}
|
||||
{s.startsWith('webhooks.') && 'Webhook-Tests auslösen'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Create Token Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neues API-Token</DialogTitle>
|
||||
<DialogDescription>Token wird kryptografisch generiert und SHA-256 gehasht gespeichert.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{newToken ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Token (nur einmal sichtbar)</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 truncate font-mono text-[11px]">{newToken}</code>
|
||||
<Button size="sm" variant="outline" className="h-7 shrink-0" onClick={copyToken}>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-muted-foreground">⚠ Speichere das Token jetzt sicher. Es kann später nicht wieder angezeigt werden.</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setCreateOpen(false)}>Fertig</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input value={tokenName} onChange={(e) => setTokenName(e.target.value)} placeholder="z.B. ZeroClaw Agent" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Scopes</Label>
|
||||
<div className="space-y-1.5">
|
||||
{allScopes.map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 rounded-md border border-border p-2">
|
||||
<Checkbox
|
||||
checked={tokenScopes.includes(s)}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) setTokenScopes((sc) => [...sc, s])
|
||||
else setTokenScopes((sc) => sc.filter((x) => x !== s))
|
||||
}}
|
||||
/>
|
||||
<code className="font-mono text-[11px]">{s}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={createToken} disabled={creating || !tokenName.trim() || tokenScopes.length === 0} className="gap-1.5">
|
||||
{creating && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Generieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Webhook Dialog */}
|
||||
<Dialog open={whCreateOpen} onOpenChange={setWhCreateOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Webhook registrieren</DialogTitle>
|
||||
<DialogDescription>HMAC-SHA256-Signatur wird für jede Auslieferung berechnet.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{newWhSecret ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Signing-Secret (nur einmal sichtbar)</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 truncate font-mono text-[11px]">{newWhSecret}</code>
|
||||
<Button size="sm" variant="outline" className="h-7" onClick={() => { navigator.clipboard.writeText(newWhSecret); toast.success('Secret kopiert') }}>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-muted-foreground">Benötigt zur Verifizierung der <code className="font-mono">x-aos-signature</code> Header auf deinem Server.</p>
|
||||
</div>
|
||||
<DialogFooter><Button onClick={() => setWhCreateOpen(false)}>Fertig</Button></DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Ziel-URL (HTTPS)</Label>
|
||||
<Input value={whUrl} onChange={(e) => setWhUrl(e.target.value)} placeholder="https://your-server.com/webhooks/aos" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Events (abonnieren)</Label>
|
||||
<div className="space-y-1.5">
|
||||
{knownEvents.map((e) => (
|
||||
<label key={e} className="flex items-center gap-2 rounded-md border border-border p-2">
|
||||
<Checkbox
|
||||
checked={whEvents.includes(e)}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) setWhEvents((ev) => [...ev, e])
|
||||
else setWhEvents((ev) => ev.filter((x) => x !== e))
|
||||
}}
|
||||
/>
|
||||
<code className="font-mono text-[11px]">{e}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWhCreateOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={createWebhook} disabled={whCreating || !whUrl.trim() || whEvents.length === 0} className="gap-1.5">
|
||||
{whCreating && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Registrieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user