Răsfoiți Sursa

feat(webui): admin pages — settings, projects, keys

Fszontagh 2 luni în urmă
părinte
comite
cd171245e5
3 a modificat fișierele cu 1173 adăugiri și 9 ștergeri
  1. 587 3
      webui/src/pages/KeysAdmin.tsx
  2. 292 3
      webui/src/pages/ProjectsAdmin.tsx
  3. 294 3
      webui/src/pages/Settings.tsx

+ 587 - 3
webui/src/pages/KeysAdmin.tsx

@@ -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() {
+  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 (
-    <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>
   )
 }

+ 292 - 3
webui/src/pages/ProjectsAdmin.tsx

@@ -1,8 +1,297 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus, Trash2 } from 'lucide-react'
+import { api } from '@/api/client'
+import { useAuthStore } from '@/stores/authStore'
+import { useToastStore } from '@/stores/toastStore'
+import { Modal } from '@/components/Modal'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
+import { ApiError } from '@/types'
+
+// ─── constants ────────────────────────────────────────────────────────────────
+
+const PROJECT_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+async function refreshAuthProjects() {
+  try {
+    const { projects } = await api.listProjects()
+    useAuthStore.setState({ projects })
+  } catch {
+    // best-effort — the store will be stale until next login, which is acceptable
+  }
+}
+
+// ─── new project form ─────────────────────────────────────────────────────────
+
+interface NewProjectFormProps {
+  onSuccess: () => void
+  onCancel: () => void
+}
+
+function NewProjectForm({ onSuccess, onCancel }: NewProjectFormProps) {
+  const push = useToastStore((s) => s.push)
+  const queryClient = useQueryClient()
+
+  const [name, setName] = useState('')
+  const [nameError, setNameError] = useState('')
+  const [submitting, setSubmitting] = useState(false)
+
+  function validate(value: string): string {
+    if (!value.trim()) return 'Name is required.'
+    if (!PROJECT_NAME_RE.test(value.trim()))
+      return 'Name must start with a letter or underscore and contain only letters, digits, underscores, or hyphens (max 63 characters).'
+    return ''
+  }
+
+  const handleNameChange = (value: string) => {
+    setName(value)
+    setNameError(validate(value))
+  }
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    const err = validate(name)
+    if (err) {
+      setNameError(err)
+      return
+    }
+    setSubmitting(true)
+    try {
+      await api.createProject(name.trim())
+      await queryClient.invalidateQueries({ queryKey: ['projects'] })
+      await refreshAuthProjects()
+      push(`Project "${name.trim()}" created.`, 'success')
+      onSuccess()
+    } catch (apiErr) {
+      const msg = apiErr instanceof ApiError ? apiErr.message : String(apiErr)
+      push(`Failed to create project: ${msg}`, 'error')
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  const inputCls =
+    'w-full rounded-md bg-slate-800 border text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:ring-1 ' +
+    (nameError
+      ? 'border-red-500 focus:border-red-500 focus:ring-red-500/40'
+      : 'border-slate-600 focus:border-indigo-500 focus:ring-indigo-500/40')
+
+  return (
+    <form onSubmit={handleSubmit} className="flex flex-col gap-4">
+      <div>
+        <label className="block text-xs font-medium text-slate-400 mb-1">
+          Project name <span className="text-red-400">*</span>
+        </label>
+        <input
+          type="text"
+          value={name}
+          onChange={(e) => handleNameChange(e.target.value)}
+          placeholder="my_project"
+          autoFocus
+          className={inputCls}
+        />
+        {nameError && <p className="mt-1 text-xs text-red-400">{nameError}</p>}
+        <p className="mt-1 text-xs text-slate-500">
+          Pattern: <span className="font-mono">^[a-zA-Z_][a-zA-Z0-9_-]&#123;0,62&#125;$</span>
+        </p>
+      </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 || !!nameError}
+          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'}
+        </button>
+      </div>
+    </form>
+  )
+}
+
+// ─── projects table ───────────────────────────────────────────────────────────
+
+interface ProjectsTableProps {
+  projects: string[]
+}
+
+function ProjectsTable({ projects }: ProjectsTableProps) {
+  const push = useToastStore((s) => s.push)
+  const queryClient = useQueryClient()
+
+  const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
+
+  const handleDelete = async (name: string) => {
+    try {
+      await api.deleteProject(name)
+      await queryClient.invalidateQueries({ queryKey: ['projects'] })
+      await refreshAuthProjects()
+      push(`Project "${name}" deleted.`, 'success')
+    } catch (err) {
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Failed to delete project: ${msg}`, 'error')
+    }
+  }
+
+  if (projects.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 projects yet. Create one to get started.</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">
+                Project name
+              </th>
+              <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
+                Actions
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            {projects.map((name, idx) => (
+              <tr
+                key={name}
+                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 font-mono text-slate-200 text-xs">
+                  {name}
+                  {name === 'default' && (
+                    <span className="ml-2 inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-slate-600/30 text-slate-400 border border-slate-600/30">
+                      default
+                    </span>
+                  )}
+                </td>
+                <td className="px-4 py-3 text-right">
+                  <button
+                    onClick={() => setDeleteTarget(name)}
+                    disabled={name === 'default'}
+                    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 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent"
+                    aria-label={`Delete ${name}`}
+                    title={name === 'default' ? 'The default project cannot be deleted.' : undefined}
+                  >
+                    <Trash2 size={13} />
+                    Delete
+                  </button>
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+
+      <ConfirmDialog
+        open={deleteTarget !== null}
+        title="Delete Project"
+        message={`Are you sure you want to delete project "${deleteTarget ?? ''}"? This is permanent.`}
+        confirmLabel="Delete"
+        onConfirm={() => {
+          if (deleteTarget) handleDelete(deleteTarget)
+        }}
+        onClose={() => setDeleteTarget(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-8">
+        <div className="h-3 rounded bg-slate-700/60" style={{ width: 120 }} />
+        <div className="h-3 rounded bg-slate-700/60" style={{ width: 60 }} />
+      </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-8">
+          <div className="h-3 rounded bg-slate-700/30" style={{ width: 120 }} />
+          <div className="h-3 rounded bg-slate-700/30" style={{ width: 60 }} />
+        </div>
+      ))}
+    </div>
+  )
+}
+
+// ─── page ─────────────────────────────────────────────────────────────────────
+
 export default function ProjectsAdmin() {
+  const push = useToastStore((s) => s.push)
+  const [newModalOpen, setNewModalOpen] = useState(false)
+
+  const { data, isLoading, isError, error } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => api.listProjects(),
+  })
+
+  if (isError && error instanceof Error) {
+    push(`Projects error: ${error.message}`, 'error')
+  }
+
+  const projects = data?.projects ?? []
+
   return (
-    <div>
-      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Projects</h1>
-      <p className="text-slate-400">Project administration — 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">Projects</h1>
+          <p className="text-slate-400 text-sm mt-1">
+            Manage all projects.
+            {!isLoading && !isError && (
+              <span className="ml-2 text-slate-500">
+                ({projects.length} {projects.length === 1 ? 'project' : 'projects'})
+              </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 project
+        </button>
+      </div>
+
+      {/* Table */}
+      {isLoading && <TableSkeleton />}
+      {isError && (
+        <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 projects: {error instanceof Error ? error.message : 'Unknown error'}
+          </p>
+        </div>
+      )}
+      {!isLoading && !isError && <ProjectsTable projects={projects} />}
+
+      {/* New project modal */}
+      <Modal open={newModalOpen} title="New Project" onClose={() => setNewModalOpen(false)}>
+        <NewProjectForm
+          onSuccess={() => setNewModalOpen(false)}
+          onCancel={() => setNewModalOpen(false)}
+        />
+      </Modal>
     </div>
   )
 }

+ 294 - 3
webui/src/pages/Settings.tsx

@@ -1,8 +1,299 @@
+import { useState, useEffect } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Save } from 'lucide-react'
+import { api } from '@/api/client'
+import { useToastStore } from '@/stores/toastStore'
+import { ApiError } from '@/types'
+import type { SettingsView } from '@/types'
+
+// ─── field helpers ────────────────────────────────────────────────────────────
+
+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'
+
+function FormRow({
+  label,
+  hint,
+  children,
+}: {
+  label: string
+  hint?: string
+  children: React.ReactNode
+}) {
+  return (
+    <div className="flex flex-col gap-1">
+      <label className="text-xs font-medium text-slate-400">{label}</label>
+      {children}
+      {hint && <p className="text-xs text-slate-500">{hint}</p>}
+    </div>
+  )
+}
+
+// ─── form state ───────────────────────────────────────────────────────────────
+
+interface FormValues {
+  openai_api_base: string
+  openai_api_key: string        // blank = don't update
+  default_embedding_model: string
+  cors_origins: string          // comma-separated in the input
+  session_ttl_minutes: string   // string for controlled input
+  webui_enabled: boolean
+  default_project: string
+}
+
+function settingsToForm(s: SettingsView): FormValues {
+  return {
+    openai_api_base: s.openai_api_base,
+    openai_api_key: '',          // never prefill a secret
+    default_embedding_model: s.default_embedding_model,
+    cors_origins: s.cors_origins.join(', '),
+    session_ttl_minutes: String(s.session_ttl_minutes),
+    webui_enabled: s.webui_enabled,
+    default_project: s.default_project,
+  }
+}
+
+// ─── page ─────────────────────────────────────────────────────────────────────
+
 export default function Settings() {
+  const push = useToastStore((s) => s.push)
+  const queryClient = useQueryClient()
+
+  const { data, isLoading, isError, error } = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => api.getSettings(),
+  })
+
+  const [form, setForm] = useState<FormValues>({
+    openai_api_base: '',
+    openai_api_key: '',
+    default_embedding_model: '',
+    cors_origins: '',
+    session_ttl_minutes: '60',
+    webui_enabled: true,
+    default_project: 'default',
+  })
+
+  // Populate the form once settings load, but don't overwrite user's edits on re-render.
+  const [populated, setPopulated] = useState(false)
+  useEffect(() => {
+    if (data && !populated) {
+      setForm(settingsToForm(data))
+      setPopulated(true)
+    }
+  }, [data, populated])
+
+  const [saving, setSaving] = useState(false)
+
+  function setField<K extends keyof FormValues>(key: K, value: FormValues[K]) {
+    setForm((prev) => ({ ...prev, [key]: value }))
+  }
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+
+    const ttl = parseInt(form.session_ttl_minutes, 10)
+    if (isNaN(ttl) || ttl <= 0) {
+      push('Session TTL must be a positive integer.', 'error')
+      return
+    }
+
+    const patch: Partial<Omit<SettingsView, 'openai_api_key_set'> & { openai_api_key?: string }> = {
+      openai_api_base: form.openai_api_base.trim(),
+      default_embedding_model: form.default_embedding_model.trim(),
+      cors_origins: form.cors_origins
+        .split(',')
+        .map((s) => s.trim())
+        .filter(Boolean),
+      session_ttl_minutes: ttl,
+      webui_enabled: form.webui_enabled,
+      default_project: form.default_project.trim(),
+    }
+
+    // Only include the key if the user typed something
+    if (form.openai_api_key.trim()) {
+      patch.openai_api_key = form.openai_api_key.trim()
+    }
+
+    setSaving(true)
+    try {
+      await api.updateSettings(patch)
+      await queryClient.invalidateQueries({ queryKey: ['settings'] })
+      // Reset populated so the form re-syncs from the refreshed data
+      setPopulated(false)
+      // Clear the key field after a successful save
+      setForm((prev) => ({ ...prev, openai_api_key: '' }))
+      push('Settings saved. Changes apply immediately (hot reload).', 'success')
+    } catch (err) {
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Failed to save settings: ${msg}`, 'error')
+    } finally {
+      setSaving(false)
+    }
+  }
+
   return (
-    <div>
-      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Settings</h1>
-      <p className="text-slate-400">Global settings management — coming in W9.</p>
+    <div className="flex flex-col gap-6 max-w-2xl">
+      {/* Page header */}
+      <div>
+        <h1 className="text-2xl font-semibold text-slate-100">Settings</h1>
+        <p className="text-slate-400 text-sm mt-1">
+          Global settings — changes apply immediately (hot reload).
+        </p>
+      </div>
+
+      {/* Error */}
+      {isError && (
+        <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 settings: {error instanceof Error ? error.message : 'Unknown error'}
+          </p>
+        </div>
+      )}
+
+      {/* Loading skeleton */}
+      {isLoading && (
+        <div className="flex flex-col gap-4 animate-pulse">
+          {[1, 2, 3, 4, 5, 6].map((i) => (
+            <div key={i} className="flex flex-col gap-1">
+              <div className="h-3 w-24 rounded bg-slate-700/60" />
+              <div className="h-9 rounded-md bg-slate-800/60" />
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* Form */}
+      {!isLoading && (
+        <form onSubmit={handleSubmit} className="flex flex-col gap-5">
+          {/* OpenAI API base */}
+          <FormRow
+            label="OpenAI API Base"
+            hint="Base URL for the OpenAI-compatible embeddings endpoint."
+          >
+            <input
+              type="text"
+              value={form.openai_api_base}
+              onChange={(e) => setField('openai_api_base', e.target.value)}
+              placeholder="https://api.openai.com/v1"
+              className={inputCls}
+            />
+          </FormRow>
+
+          {/* OpenAI API key */}
+          <FormRow
+            label="OpenAI API Key"
+            hint={
+              data?.openai_api_key_set
+                ? 'A key is currently set. Leave blank to keep it unchanged.'
+                : 'No key is currently set. Enter one to configure it.'
+            }
+          >
+            <input
+              type="password"
+              value={form.openai_api_key}
+              onChange={(e) => setField('openai_api_key', e.target.value)}
+              placeholder={data?.openai_api_key_set ? 'set — leave blank to keep' : 'not set'}
+              autoComplete="new-password"
+              className={inputCls}
+            />
+          </FormRow>
+
+          {/* Default embedding model */}
+          <FormRow
+            label="Default Embedding Model"
+            hint="Used when a vector collection doesn't specify a model."
+          >
+            <input
+              type="text"
+              value={form.default_embedding_model}
+              onChange={(e) => setField('default_embedding_model', e.target.value)}
+              placeholder="text-embedding-3-small"
+              className={inputCls}
+            />
+          </FormRow>
+
+          {/* CORS origins */}
+          <FormRow
+            label="CORS Origins"
+            hint="Comma-separated list of allowed origins (e.g. http://localhost:3000, https://app.example.com)."
+          >
+            <input
+              type="text"
+              value={form.cors_origins}
+              onChange={(e) => setField('cors_origins', e.target.value)}
+              placeholder="http://localhost:3000, https://app.example.com"
+              className={inputCls}
+            />
+          </FormRow>
+
+          {/* Session TTL */}
+          <FormRow
+            label="Session TTL (minutes)"
+            hint="How long an API-key session cookie remains valid."
+          >
+            <input
+              type="number"
+              value={form.session_ttl_minutes}
+              onChange={(e) => setField('session_ttl_minutes', e.target.value)}
+              min={1}
+              placeholder="60"
+              className={inputCls}
+            />
+          </FormRow>
+
+          {/* Default project */}
+          <FormRow
+            label="Default Project"
+            hint="The project used when no project is specified."
+          >
+            <input
+              type="text"
+              value={form.default_project}
+              onChange={(e) => setField('default_project', e.target.value)}
+              placeholder="default"
+              className={inputCls}
+            />
+          </FormRow>
+
+          {/* WebUI enabled */}
+          <FormRow label="WebUI Enabled" hint="Disable to turn off the built-in web interface.">
+            <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
+              <div
+                role="switch"
+                aria-checked={form.webui_enabled}
+                onClick={() => setField('webui_enabled', !form.webui_enabled)}
+                className={[
+                  'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none',
+                  form.webui_enabled ? 'bg-indigo-600' : 'bg-slate-600',
+                ].join(' ')}
+              >
+                <span
+                  className={[
+                    'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform',
+                    form.webui_enabled ? 'translate-x-5' : 'translate-x-0',
+                  ].join(' ')}
+                />
+              </div>
+              <span className="text-sm text-slate-300">
+                {form.webui_enabled ? 'Enabled' : 'Disabled'}
+              </span>
+            </label>
+          </FormRow>
+
+          {/* Save */}
+          <div className="pt-2">
+            <button
+              type="submit"
+              disabled={saving}
+              className="flex items-center gap-2 px-5 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
+            >
+              <Save size={15} />
+              {saving ? 'Saving…' : 'Save settings'}
+            </button>
+          </div>
+        </form>
+      )}
     </div>
   )
 }