Bladeren bron

feat(webui): scoped (publishable) key builder in KeysAdmin

Add scope types (KeyOp, KeyScopeRule, KeyScope) to types/index.ts and
optional scope? on ApiKeyPublic. Widen api.createKey() to accept an
optional KeyScope. NewKeyForm gains a "Make this a scoped key" toggle
that forces admin off, reveals a dynamic rule editor (collection +
6 op checkboxes + CAPTCHA checkbox per row), origins, rate-limit, and
expires-at inputs, with a client-side guard preventing empty-rule
submit. KeysTable shows a "scoped" badge and compact scope summary
(collection[ops] · N/min) in the Scope/Projects column.
Fszontagh 1 maand geleden
bovenliggende
commit
12341946be
3 gewijzigde bestanden met toevoegingen van 270 en 17 verwijderingen
  1. 3 2
      webui/src/api/client.ts
  2. 254 15
      webui/src/pages/KeysAdmin.tsx
  3. 13 0
      webui/src/types/index.ts

+ 3 - 2
webui/src/api/client.ts

@@ -1,6 +1,7 @@
 import type {
   LoginResponse, CollectionMeta, ApiKeyPublic, ApiKeyCreated,
   SearchResult, ProjectStats, GlobalStats, FindResult, SettingsView,
+  KeyScope,
 } from '@/types'
 import { ApiError } from '@/types'
 
@@ -36,8 +37,8 @@ export const api = {
 
   // keys (admin)
   listKeys: () => request<{ keys: ApiKeyPublic[] }>('/api/v1/keys'),
-  createKey: (label: string, projects: string[], admin: boolean) =>
-    request<ApiKeyCreated>('/api/v1/keys', { method: 'POST', body: JSON.stringify({ label, projects, admin }) }),
+  createKey: (label: string, projects: string[], admin: boolean, scope?: KeyScope) =>
+    request<ApiKeyCreated>('/api/v1/keys', { method: 'POST', body: JSON.stringify(scope ? { label, projects, admin, scope } : { label, projects, admin }) }),
   updateKey: (key: string, patch: Partial<{ label: string; projects: string[]; admin: boolean }>) =>
     request<{ updated: boolean }>(`/api/v1/keys/${enc(key)}`, { method: 'PATCH', body: JSON.stringify(patch) }),
   deleteKey: (key: string) => request<{ deleted: boolean }>(`/api/v1/keys/${enc(key)}`, { method: 'DELETE' }),

+ 254 - 15
webui/src/pages/KeysAdmin.tsx

@@ -6,7 +6,7 @@ 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'
+import type { ApiKeyPublic, ApiKeyCreated, KeyOp, KeyScopeRule, KeyScope } from '@/types'
 
 // ─── helpers ──────────────────────────────────────────────────────────────────
 
@@ -23,6 +23,17 @@ function formatDate(epoch: number): string {
 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'
 
+const KEY_OPS = ['read', 'list', 'search', 'insert', 'update', 'delete'] as const
+
+function scopeSummary(scope: KeyScope): string {
+  const rules = scope.rules
+    .filter((r) => r.collection && r.ops.length > 0)
+    .map((r) => `${r.collection}[${r.ops.join(',')}]`)
+    .join(', ')
+  const rate = scope.rate_limit_per_min > 0 ? ` · ${scope.rate_limit_per_min}/min` : ''
+  return `scoped: ${rules || '(no rules)'}${rate}`
+}
+
 // ─── project chips ────────────────────────────────────────────────────────────
 
 function ProjectChips({ projects }: { projects: string[] }) {
@@ -103,6 +114,10 @@ interface NewKeyFormProps {
   onCancel: () => void
 }
 
+function emptyRule(): KeyScopeRule {
+  return { collection: '', ops: [], require_human_token: false }
+}
+
 function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
   const push = useToastStore((s) => s.push)
 
@@ -111,15 +126,72 @@ function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
   const [projects, setProjects] = useState<string[]>([])
   const [submitting, setSubmitting] = useState(false)
 
+  // scoped key state
+  const [isScoped, setIsScoped] = useState(false)
+  const [rules, setRules] = useState<KeyScopeRule[]>([emptyRule()])
+  const [origins, setOrigins] = useState('')
+  const [rateLimit, setRateLimit] = useState(0)
+  const [expiresAt, setExpiresAt] = useState(0)
+
+  const handleScopedToggle = () => {
+    const next = !isScoped
+    setIsScoped(next)
+    if (next) setIsAdmin(false)
+  }
+
+  const handleAdminToggle = () => {
+    if (isScoped) return // admin is forced off when scoped
+    setIsAdmin((v) => !v)
+  }
+
+  const updateRule = (idx: number, patch: Partial<KeyScopeRule>) => {
+    setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)))
+  }
+
+  const toggleRuleOp = (idx: number, op: KeyOp) => {
+    setRules((prev) =>
+      prev.map((r, i) => {
+        if (i !== idx) return r
+        const ops = r.ops.includes(op) ? r.ops.filter((o) => o !== op) : [...r.ops, op]
+        return { ...r, ops }
+      }),
+    )
+  }
+
+  const addRule = () => setRules((prev) => [...prev, emptyRule()])
+  const removeRule = (idx: number) => setRules((prev) => prev.filter((_, i) => i !== idx))
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
     if (!label.trim()) {
       push('Label is required.', 'error')
       return
     }
+    if (isScoped) {
+      const validRules = rules.filter((r) => r.collection.trim() && r.ops.length > 0)
+      if (validRules.length === 0) {
+        push('A scoped key needs at least one rule with a collection name and one operation.', 'error')
+        return
+      }
+    }
     setSubmitting(true)
     try {
-      const created = await api.createKey(label.trim(), projects, isAdmin)
+      let scope: KeyScope | undefined
+      if (isScoped) {
+        const validRules = rules
+          .filter((r) => r.collection.trim() && r.ops.length > 0)
+          .map((r) => ({
+            collection: r.collection.trim(),
+            ops: r.ops,
+            require_human_token: r.require_human_token ?? false,
+          }))
+        const originsArr = origins
+          .split(',')
+          .map((o) => o.trim())
+          .filter((o) => o.length > 0)
+        scope = { rules: validRules, origins: originsArr, rate_limit_per_min: rateLimit, expires_at: expiresAt }
+      }
+      const created = await api.createKey(label.trim(), projects, isAdmin, scope)
       onCreated(created)
     } catch (err) {
       const msg = err instanceof ApiError ? err.message : String(err)
@@ -149,11 +221,16 @@ function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
       {/* 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">
+        <label
+          className={[
+            'flex items-center gap-3 select-none w-fit',
+            isScoped ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer',
+          ].join(' ')}
+        >
           <div
             role="switch"
             aria-checked={isAdmin}
-            onClick={() => setIsAdmin((v) => !v)}
+            onClick={handleAdminToggle}
             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',
@@ -179,6 +256,157 @@ function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
         <ProjectsPicker allProjects={allProjects} selected={projects} onChange={setProjects} />
       </div>
 
+      {/* Scoped key toggle */}
+      <div className="border-t border-slate-700/40 pt-4">
+        <label className="flex items-center gap-3 cursor-pointer select-none w-fit">
+          <div
+            role="switch"
+            aria-checked={isScoped}
+            onClick={handleScopedToggle}
+            className={[
+              'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors',
+              isScoped ? 'bg-teal-600' : 'bg-slate-600',
+            ].join(' ')}
+          >
+            <span
+              className={[
+                'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform',
+                isScoped ? 'translate-x-5' : 'translate-x-0',
+              ].join(' ')}
+            />
+          </div>
+          <span className="text-sm text-slate-300 font-medium">Make this a scoped (publishable) key</span>
+        </label>
+        {isScoped && (
+          <p className="text-xs text-slate-500 mt-1 ml-14">
+            Scoped keys are safe to expose client-side — enforced by collection rules, origins, and rate limits.
+          </p>
+        )}
+      </div>
+
+      {/* Scope editor */}
+      {isScoped && (
+        <div className="flex flex-col gap-4 rounded-lg border border-teal-700/30 bg-teal-900/10 px-4 py-4">
+          {/* Rules */}
+          <div>
+            <div className="flex items-center justify-between mb-2">
+              <label className="text-xs font-medium text-slate-400">
+                Collection rules <span className="text-red-400">*</span>
+              </label>
+              <button
+                type="button"
+                onClick={addRule}
+                className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-teal-300 hover:text-teal-200 bg-teal-700/20 hover:bg-teal-700/40 border border-teal-700/30 transition"
+              >
+                <Plus size={11} />
+                Add rule
+              </button>
+            </div>
+
+            <div className="flex flex-col gap-3">
+              {rules.map((rule, idx) => (
+                <div
+                  key={idx}
+                  className="rounded-md border border-slate-700/40 bg-slate-800/50 px-3 py-3 flex flex-col gap-2"
+                >
+                  <div className="flex items-center gap-2">
+                    <input
+                      type="text"
+                      value={rule.collection}
+                      onChange={(e) => updateRule(idx, { collection: e.target.value })}
+                      placeholder="collection name"
+                      className="flex-1 rounded-md bg-slate-900 border border-slate-600 text-slate-100 px-3 py-1.5 text-xs placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
+                    />
+                    {rules.length > 1 && (
+                      <button
+                        type="button"
+                        onClick={() => removeRule(idx)}
+                        className="inline-flex items-center px-2 py-1.5 rounded text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 transition"
+                        aria-label="Remove rule"
+                      >
+                        <Trash2 size={13} />
+                      </button>
+                    )}
+                  </div>
+                  <div className="flex flex-wrap gap-2">
+                    {KEY_OPS.map((op) => {
+                      const active = rule.ops.includes(op)
+                      return (
+                        <button
+                          key={op}
+                          type="button"
+                          onClick={() => toggleRuleOp(idx, op)}
+                          className={[
+                            'inline-flex items-center px-2 py-0.5 rounded text-xs font-mono border transition',
+                            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(' ')}
+                        >
+                          {op}
+                        </button>
+                      )
+                    })}
+                  </div>
+                  <label className="flex items-center gap-2 cursor-pointer select-none w-fit">
+                    <input
+                      type="checkbox"
+                      checked={rule.require_human_token ?? false}
+                      onChange={(e) => updateRule(idx, { require_human_token: e.target.checked })}
+                      className="w-3.5 h-3.5 rounded border-slate-600 bg-slate-800 accent-indigo-500"
+                    />
+                    <span className="text-xs text-slate-400">Require human token (CAPTCHA)</span>
+                  </label>
+                </div>
+              ))}
+            </div>
+          </div>
+
+          {/* Origins */}
+          <div>
+            <label className="block text-xs font-medium text-slate-400 mb-1">
+              Allowed origins{' '}
+              <span className="text-slate-500">(comma-separated, e.g. https://app.example.com)</span>
+            </label>
+            <input
+              type="text"
+              value={origins}
+              onChange={(e) => setOrigins(e.target.value)}
+              placeholder="https://app.example.com, https://other.example.com"
+              className={inputCls}
+            />
+          </div>
+
+          {/* Rate limit */}
+          <div>
+            <label className="block text-xs font-medium text-slate-400 mb-1">
+              Rate limit / min <span className="text-slate-500">(0 = unlimited)</span>
+            </label>
+            <input
+              type="number"
+              min={0}
+              value={rateLimit}
+              onChange={(e) => setRateLimit(Number(e.target.value))}
+              className={inputCls}
+            />
+          </div>
+
+          {/* Expires at */}
+          <div>
+            <label className="block text-xs font-medium text-slate-400 mb-1">
+              Expires at <span className="text-slate-500">(Unix epoch seconds; 0 = never)</span>
+            </label>
+            <input
+              type="number"
+              min={0}
+              value={expiresAt}
+              onChange={(e) => setExpiresAt(Number(e.target.value))}
+              className={inputCls}
+            />
+          </div>
+        </div>
+      )}
+
       <div className="flex items-center justify-end gap-3 pt-1">
         <button
           type="button"
@@ -395,7 +623,7 @@ function KeysTable({ keys, allProjects }: KeysTableProps) {
                 Type
               </th>
               <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
-                Projects
+                Scope / Projects
               </th>
               <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
                 Created
@@ -417,18 +645,29 @@ function KeysTable({ keys, allProjects }: KeysTableProps) {
                 <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>
-                  )}
+                  <div className="flex flex-wrap gap-1">
+                    {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>
+                    )}
+                    {k.scope && (
+                      <span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-teal-500/15 text-teal-300 border border-teal-500/30">
+                        scoped
+                      </span>
+                    )}
+                  </div>
                 </td>
                 <td className="px-4 py-3">
-                  <ProjectChips projects={k.projects} />
+                  {k.scope ? (
+                    <span className="text-xs text-teal-400/80 font-mono">{scopeSummary(k.scope)}</span>
+                  ) : (
+                    <ProjectChips projects={k.projects} />
+                  )}
                 </td>
                 <td className="px-4 py-3 text-slate-400 text-xs whitespace-nowrap">
                   {formatDate(k.created_at)}

+ 13 - 0
webui/src/types/index.ts

@@ -4,8 +4,21 @@ export interface CollectionMeta {
   embedding_model: string; created_at: number;
   document_count?: number; size_bytes?: number;
 }
+export type KeyOp = 'read' | 'list' | 'search' | 'insert' | 'update' | 'delete'
+export interface KeyScopeRule {
+  collection: string
+  ops: KeyOp[]
+  require_human_token?: boolean
+}
+export interface KeyScope {
+  rules: KeyScopeRule[]
+  origins: string[]
+  rate_limit_per_min: number
+  expires_at: number
+}
 export interface ApiKeyPublic {
   id: string; label: string; projects: string[]; admin: boolean; created_at: number; key_prefix: string;
+  scope?: KeyScope
 }
 export interface ApiKeyCreated extends ApiKeyPublic { key: string }
 export interface SearchResult { id: string; score: number; data: Record<string, unknown> }