|
@@ -1,8 +1,592 @@
|
|
|
|
|
+import { useState } from 'react'
|
|
|
|
|
+import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
+import { Plus, Trash2, Pencil, Copy, Check } from 'lucide-react'
|
|
|
|
|
+import { api } from '@/api/client'
|
|
|
|
|
+import { useToastStore } from '@/stores/toastStore'
|
|
|
|
|
+import { Modal } from '@/components/Modal'
|
|
|
|
|
+import { ConfirmDialog } from '@/components/ConfirmDialog'
|
|
|
|
|
+import { ApiError } from '@/types'
|
|
|
|
|
+import type { ApiKeyPublic, ApiKeyCreated } from '@/types'
|
|
|
|
|
+
|
|
|
|
|
+// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function formatDate(epoch: number): string {
|
|
|
|
|
+ return new Date(epoch * 1000).toLocaleString(undefined, {
|
|
|
|
|
+ year: 'numeric',
|
|
|
|
|
+ month: 'short',
|
|
|
|
|
+ day: 'numeric',
|
|
|
|
|
+ hour: '2-digit',
|
|
|
|
|
+ minute: '2-digit',
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const inputCls =
|
|
|
|
|
+ 'w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40'
|
|
|
|
|
+
|
|
|
|
|
+// ─── project chips ────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function ProjectChips({ projects }: { projects: string[] }) {
|
|
|
|
|
+ if (projects.length === 0) return <span className="text-slate-500 text-xs">none</span>
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1">
|
|
|
|
|
+ {projects.map((p) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={p}
|
|
|
|
|
+ className="inline-block px-1.5 py-0.5 rounded text-xs font-mono bg-slate-700/50 text-slate-300 border border-slate-600/40"
|
|
|
|
|
+ >
|
|
|
|
|
+ {p}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── projects multi-select ────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface ProjectsPickerProps {
|
|
|
|
|
+ allProjects: string[]
|
|
|
|
|
+ selected: string[]
|
|
|
|
|
+ onChange: (next: string[]) => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function ProjectsPicker({ allProjects, selected, onChange }: ProjectsPickerProps) {
|
|
|
|
|
+ const options = ['*', ...allProjects]
|
|
|
|
|
+
|
|
|
|
|
+ const toggle = (p: string) => {
|
|
|
|
|
+ if (p === '*') {
|
|
|
|
|
+ // toggling '*' clears specific projects or sets only '*'
|
|
|
|
|
+ if (selected.includes('*')) {
|
|
|
|
|
+ onChange([])
|
|
|
|
|
+ } else {
|
|
|
|
|
+ onChange(['*'])
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // if '*' is currently selected, replacing with specific
|
|
|
|
|
+ const base = selected.includes('*') ? [] : selected
|
|
|
|
|
+ if (base.includes(p)) {
|
|
|
|
|
+ onChange(base.filter((x) => x !== p))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ onChange([...base, p])
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto pr-1">
|
|
|
|
|
+ {options.map((p) => {
|
|
|
|
|
+ const active = selected.includes(p)
|
|
|
|
|
+ return (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={p}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => toggle(p)}
|
|
|
|
|
+ className={[
|
|
|
|
|
+ 'inline-flex items-center px-2.5 py-1 rounded-md text-xs font-mono transition border',
|
|
|
|
|
+ active
|
|
|
|
|
+ ? 'bg-indigo-600/30 border-indigo-500/50 text-indigo-300'
|
|
|
|
|
+ : 'bg-slate-800 border-slate-600/40 text-slate-400 hover:border-slate-500 hover:text-slate-300',
|
|
|
|
|
+ ].join(' ')}
|
|
|
|
|
+ >
|
|
|
|
|
+ {p === '*' ? '* (all projects)' : p}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── new key form ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface NewKeyFormProps {
|
|
|
|
|
+ allProjects: string[]
|
|
|
|
|
+ onCreated: (created: ApiKeyCreated) => void
|
|
|
|
|
+ onCancel: () => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+
|
|
|
|
|
+ const [label, setLabel] = useState('')
|
|
|
|
|
+ const [isAdmin, setIsAdmin] = useState(false)
|
|
|
|
|
+ const [projects, setProjects] = useState<string[]>([])
|
|
|
|
|
+ const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const handleSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (!label.trim()) {
|
|
|
|
|
+ push('Label is required.', 'error')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ setSubmitting(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const created = await api.createKey(label.trim(), projects, isAdmin)
|
|
|
|
|
+ onCreated(created)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
|
|
+ push(`Failed to create key: ${msg}`, 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSubmitting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
|
|
|
+ {/* Label */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-xs font-medium text-slate-400 mb-1">
|
|
|
|
|
+ Label <span className="text-red-400">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={label}
|
|
|
|
|
+ onChange={(e) => setLabel(e.target.value)}
|
|
|
|
|
+ placeholder="My API key"
|
|
|
|
|
+ autoFocus
|
|
|
|
|
+ className={inputCls}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Admin toggle */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs font-medium text-slate-400 block mb-2">Admin privileges</label>
|
|
|
|
|
+ <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
|
|
|
|
|
+ <div
|
|
|
|
|
+ role="switch"
|
|
|
|
|
+ aria-checked={isAdmin}
|
|
|
|
|
+ onClick={() => setIsAdmin((v) => !v)}
|
|
|
|
|
+ className={[
|
|
|
|
|
+ 'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors',
|
|
|
|
|
+ isAdmin ? 'bg-indigo-600' : 'bg-slate-600',
|
|
|
|
|
+ ].join(' ')}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span
|
|
|
|
|
+ className={[
|
|
|
|
|
+ 'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform',
|
|
|
|
|
+ isAdmin ? 'translate-x-5' : 'translate-x-0',
|
|
|
|
|
+ ].join(' ')}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span className="text-sm text-slate-300">{isAdmin ? 'Admin' : 'Non-admin'}</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Projects */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs font-medium text-slate-400 block mb-2">
|
|
|
|
|
+ Project grants{' '}
|
|
|
|
|
+ <span className="text-slate-500">(select projects this key can access)</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <ProjectsPicker allProjects={allProjects} selected={projects} onChange={setProjects} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex items-center justify-end gap-3 pt-1">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={onCancel}
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-slate-300 hover:text-slate-100 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ {submitting ? 'Creating…' : 'Create key'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── key reveal modal ─────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface KeyRevealModalProps {
|
|
|
|
|
+ keyValue: string
|
|
|
|
|
+ onClose: () => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function KeyRevealModal({ keyValue, onClose }: KeyRevealModalProps) {
|
|
|
|
|
+ const [copied, setCopied] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const handleCopy = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await navigator.clipboard.writeText(keyValue)
|
|
|
|
|
+ setCopied(true)
|
|
|
|
|
+ setTimeout(() => setCopied(false), 2000)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // clipboard access failed — the user can still select-copy manually
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Modal open title="API Key Created — Copy Now" onClose={onClose}>
|
|
|
|
|
+ <div className="flex flex-col gap-4">
|
|
|
|
|
+ <div className="rounded-lg bg-amber-500/10 border border-amber-500/30 px-4 py-3">
|
|
|
|
|
+ <p className="text-amber-300 text-sm font-medium">
|
|
|
|
|
+ This is the only time the full key will be shown. Copy it now and store it securely.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <input
|
|
|
|
|
+ readOnly
|
|
|
|
|
+ value={keyValue}
|
|
|
|
|
+ onClick={(e) => (e.target as HTMLInputElement).select()}
|
|
|
|
|
+ className="flex-1 rounded-md bg-slate-950 border border-slate-600 text-green-300 font-mono text-xs px-3 py-2 focus:outline-none focus:border-indigo-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={handleCopy}
|
|
|
|
|
+ className="flex items-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium bg-slate-700 hover:bg-slate-600 text-slate-200 transition shrink-0"
|
|
|
|
|
+ aria-label="Copy key to clipboard"
|
|
|
|
|
+ >
|
|
|
|
|
+ {copied ? <Check size={15} className="text-green-400" /> : <Copy size={15} />}
|
|
|
|
|
+ {copied ? 'Copied!' : 'Copy'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex justify-end pt-1">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={onClose}
|
|
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition"
|
|
|
|
|
+ >
|
|
|
|
|
+ Done
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── edit grants modal ────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface EditGrantsModalProps {
|
|
|
|
|
+ keyRecord: ApiKeyPublic
|
|
|
|
|
+ allProjects: string[]
|
|
|
|
|
+ onClose: () => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function EditGrantsModal({ keyRecord, allProjects, onClose }: EditGrantsModalProps) {
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+ const queryClient = useQueryClient()
|
|
|
|
|
+
|
|
|
|
|
+ const [projects, setProjects] = useState<string[]>(keyRecord.projects)
|
|
|
|
|
+ const [isAdmin, setIsAdmin] = useState(keyRecord.admin)
|
|
|
|
|
+ const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const handleSave = async () => {
|
|
|
|
|
+ setSubmitting(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await api.updateKey(keyRecord.key_prefix, { projects, admin: isAdmin })
|
|
|
|
|
+ await queryClient.invalidateQueries({ queryKey: ['keys'] })
|
|
|
|
|
+ push(`Key "${keyRecord.label}" updated.`, 'success')
|
|
|
|
|
+ onClose()
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
|
|
+ push(`Failed to update key: ${msg}`, 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSubmitting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Modal open title={`Edit grants: ${keyRecord.label}`} onClose={onClose}>
|
|
|
|
|
+ <div className="flex flex-col gap-4">
|
|
|
|
|
+ {/* Admin toggle */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs font-medium text-slate-400 block mb-2">Admin privileges</label>
|
|
|
|
|
+ <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
|
|
|
|
|
+ <div
|
|
|
|
|
+ role="switch"
|
|
|
|
|
+ aria-checked={isAdmin}
|
|
|
|
|
+ onClick={() => setIsAdmin((v) => !v)}
|
|
|
|
|
+ className={[
|
|
|
|
|
+ 'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors',
|
|
|
|
|
+ isAdmin ? 'bg-indigo-600' : 'bg-slate-600',
|
|
|
|
|
+ ].join(' ')}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span
|
|
|
|
|
+ className={[
|
|
|
|
|
+ 'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform',
|
|
|
|
|
+ isAdmin ? 'translate-x-5' : 'translate-x-0',
|
|
|
|
|
+ ].join(' ')}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span className="text-sm text-slate-300">{isAdmin ? 'Admin' : 'Non-admin'}</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Projects */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-xs font-medium text-slate-400 block mb-2">Project grants</label>
|
|
|
|
|
+ <ProjectsPicker allProjects={allProjects} selected={projects} onChange={setProjects} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex items-center justify-end gap-3 pt-1">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={onClose}
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-slate-300 hover:text-slate-100 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={handleSave}
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ {submitting ? 'Saving…' : 'Save'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── keys table ───────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface KeysTableProps {
|
|
|
|
|
+ keys: ApiKeyPublic[]
|
|
|
|
|
+ allProjects: string[]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function KeysTable({ keys, allProjects }: KeysTableProps) {
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+ const queryClient = useQueryClient()
|
|
|
|
|
+
|
|
|
|
|
+ const [revokeTarget, setRevokeTarget] = useState<ApiKeyPublic | null>(null)
|
|
|
|
|
+ const [editTarget, setEditTarget] = useState<ApiKeyPublic | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ const handleRevoke = async (keyRecord: ApiKeyPublic) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await api.deleteKey(keyRecord.key_prefix)
|
|
|
|
|
+ await queryClient.invalidateQueries({ queryKey: ['keys'] })
|
|
|
|
|
+ push(`Key "${keyRecord.label}" revoked.`, 'success')
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
|
|
+ push(`Failed to revoke key: ${msg}`, 'error')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (keys.length === 0) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-12 text-center">
|
|
|
|
|
+ <p className="text-slate-400 text-sm">No API keys yet.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 overflow-hidden">
|
|
|
|
|
+ <table className="w-full text-sm">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr className="border-b border-slate-700/40 bg-slate-800/60">
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
|
|
|
+ Label
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
|
|
|
+ Prefix
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
|
|
|
+ Type
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
|
|
|
+ Projects
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
|
|
|
+ Created
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
|
|
|
+ Actions
|
|
|
|
|
+ </th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {keys.map((k, idx) => (
|
|
|
|
|
+ <tr
|
|
|
|
|
+ key={k.key_prefix}
|
|
|
|
|
+ className={[
|
|
|
|
|
+ 'border-b border-slate-700/20 transition hover:bg-slate-800/40',
|
|
|
|
|
+ idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
|
|
|
|
|
+ ].join(' ')}
|
|
|
|
|
+ >
|
|
|
|
|
+ <td className="px-4 py-3 text-slate-200 text-sm font-medium">{k.label}</td>
|
|
|
|
|
+ <td className="px-4 py-3 font-mono text-slate-400 text-xs">{k.key_prefix}…</td>
|
|
|
|
|
+ <td className="px-4 py-3">
|
|
|
|
|
+ {k.admin ? (
|
|
|
|
|
+ <span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
|
|
|
|
+ admin
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-slate-600/30 text-slate-400 border border-slate-600/30">
|
|
|
|
|
+ key
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3">
|
|
|
|
|
+ <ProjectChips projects={k.projects} />
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3 text-slate-400 text-xs whitespace-nowrap">
|
|
|
|
|
+ {formatDate(k.created_at)}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3 text-right">
|
|
|
|
|
+ <div className="flex items-center justify-end gap-1">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setEditTarget(k)}
|
|
|
|
|
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition"
|
|
|
|
|
+ aria-label={`Edit grants for ${k.label}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Pencil size={13} />
|
|
|
|
|
+ Edit
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setRevokeTarget(k)}
|
|
|
|
|
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 transition"
|
|
|
|
|
+ aria-label={`Revoke ${k.label}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 size={13} />
|
|
|
|
|
+ Revoke
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Revoke confirm */}
|
|
|
|
|
+ <ConfirmDialog
|
|
|
|
|
+ open={revokeTarget !== null}
|
|
|
|
|
+ title="Revoke API Key"
|
|
|
|
|
+ message={`Are you sure you want to revoke "${revokeTarget?.label ?? ''}"? This cannot be undone.`}
|
|
|
|
|
+ confirmLabel="Revoke"
|
|
|
|
|
+ onConfirm={() => {
|
|
|
|
|
+ if (revokeTarget) handleRevoke(revokeTarget)
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClose={() => setRevokeTarget(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Edit grants modal */}
|
|
|
|
|
+ {editTarget && (
|
|
|
|
|
+ <EditGrantsModal
|
|
|
|
|
+ keyRecord={editTarget}
|
|
|
|
|
+ allProjects={allProjects}
|
|
|
|
|
+ onClose={() => setEditTarget(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── skeleton ─────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function TableSkeleton() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 overflow-hidden animate-pulse">
|
|
|
|
|
+ <div className="bg-slate-800/60 px-4 py-3 flex gap-6">
|
|
|
|
|
+ {[80, 80, 50, 100, 100, 60].map((w, i) => (
|
|
|
|
|
+ <div key={i} className="h-3 rounded bg-slate-700/60" style={{ width: w }} />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {[1, 2, 3].map((i) => (
|
|
|
|
|
+ <div key={i} className="border-t border-slate-700/20 bg-slate-900/20 px-4 py-4 flex gap-6">
|
|
|
|
|
+ {[80, 80, 50, 100, 100, 60].map((w, j) => (
|
|
|
|
|
+ <div key={j} className="h-3 rounded bg-slate-700/30" style={{ width: w }} />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── page ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
export default function KeysAdmin() {
|
|
export default function KeysAdmin() {
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+ const queryClient = useQueryClient()
|
|
|
|
|
+
|
|
|
|
|
+ const [newModalOpen, setNewModalOpen] = useState(false)
|
|
|
|
|
+ const [revealKey, setRevealKey] = useState<ApiKeyCreated | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ const { data: keysData, isLoading: keysLoading, isError: keysError, error: keysErr } = useQuery({
|
|
|
|
|
+ queryKey: ['keys'],
|
|
|
|
|
+ queryFn: () => api.listKeys(),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const { data: projectsData } = useQuery({
|
|
|
|
|
+ queryKey: ['projects'],
|
|
|
|
|
+ queryFn: () => api.listProjects(),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (keysError && keysErr instanceof Error) {
|
|
|
|
|
+ push(`Keys error: ${keysErr.message}`, 'error')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const keys = keysData?.keys ?? []
|
|
|
|
|
+ const allProjects = projectsData?.projects ?? []
|
|
|
|
|
+
|
|
|
|
|
+ const handleKeyCreated = async (created: ApiKeyCreated) => {
|
|
|
|
|
+ await queryClient.invalidateQueries({ queryKey: ['keys'] })
|
|
|
|
|
+ setNewModalOpen(false)
|
|
|
|
|
+ setRevealKey(created)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
- <div>
|
|
|
|
|
- <h1 className="text-2xl font-semibold text-slate-100 mb-2">Keys</h1>
|
|
|
|
|
- <p className="text-slate-400">API key management — coming in W9.</p>
|
|
|
|
|
|
|
+ <div className="flex flex-col gap-6">
|
|
|
|
|
+ {/* Page header */}
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">API Keys</h1>
|
|
|
|
|
+ <p className="text-slate-400 text-sm mt-1">
|
|
|
|
|
+ Manage API keys and their project grants.
|
|
|
|
|
+ {!keysLoading && !keysError && (
|
|
|
|
|
+ <span className="ml-2 text-slate-500">
|
|
|
|
|
+ ({keys.length} {keys.length === 1 ? 'key' : 'keys'})
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setNewModalOpen(true)}
|
|
|
|
|
+ className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Plus size={16} />
|
|
|
|
|
+ New key
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Table */}
|
|
|
|
|
+ {keysLoading && <TableSkeleton />}
|
|
|
|
|
+ {keysError && (
|
|
|
|
|
+ <div className="rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-4">
|
|
|
|
|
+ <p className="text-red-400 text-sm">
|
|
|
|
|
+ Failed to load keys: {keysErr instanceof Error ? keysErr.message : 'Unknown error'}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!keysLoading && !keysError && <KeysTable keys={keys} allProjects={allProjects} />}
|
|
|
|
|
+
|
|
|
|
|
+ {/* New key modal */}
|
|
|
|
|
+ <Modal open={newModalOpen} title="New API Key" onClose={() => setNewModalOpen(false)}>
|
|
|
|
|
+ <NewKeyForm
|
|
|
|
|
+ allProjects={allProjects}
|
|
|
|
|
+ onCreated={handleKeyCreated}
|
|
|
|
|
+ onCancel={() => setNewModalOpen(false)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Key reveal — shown once after creation */}
|
|
|
|
|
+ {revealKey && (
|
|
|
|
|
+ <KeyRevealModal
|
|
|
|
|
+ keyValue={revealKey.key}
|
|
|
|
|
+ onClose={() => setRevealKey(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|