|
@@ -6,7 +6,7 @@ import { useToastStore } from '@/stores/toastStore'
|
|
|
import { Modal } from '@/components/Modal'
|
|
import { Modal } from '@/components/Modal'
|
|
|
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
|
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
|
|
import { ApiError } from '@/types'
|
|
import { ApiError } from '@/types'
|
|
|
-import type { ApiKeyPublic, ApiKeyCreated } from '@/types'
|
|
|
|
|
|
|
+import type { ApiKeyPublic, ApiKeyCreated, KeyOp, KeyScopeRule, KeyScope } from '@/types'
|
|
|
|
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
@@ -23,6 +23,17 @@ function formatDate(epoch: number): string {
|
|
|
const inputCls =
|
|
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'
|
|
'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 ────────────────────────────────────────────────────────────
|
|
// ─── project chips ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
function ProjectChips({ projects }: { projects: string[] }) {
|
|
function ProjectChips({ projects }: { projects: string[] }) {
|
|
@@ -103,6 +114,10 @@ interface NewKeyFormProps {
|
|
|
onCancel: () => void
|
|
onCancel: () => void
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function emptyRule(): KeyScopeRule {
|
|
|
|
|
+ return { collection: '', ops: [], require_human_token: false }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
|
|
function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
|
|
|
const push = useToastStore((s) => s.push)
|
|
const push = useToastStore((s) => s.push)
|
|
|
|
|
|
|
@@ -111,15 +126,72 @@ function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
|
|
|
const [projects, setProjects] = useState<string[]>([])
|
|
const [projects, setProjects] = useState<string[]>([])
|
|
|
const [submitting, setSubmitting] = useState(false)
|
|
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) => {
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
|
e.preventDefault()
|
|
e.preventDefault()
|
|
|
if (!label.trim()) {
|
|
if (!label.trim()) {
|
|
|
push('Label is required.', 'error')
|
|
push('Label is required.', 'error')
|
|
|
return
|
|
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)
|
|
setSubmitting(true)
|
|
|
try {
|
|
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)
|
|
onCreated(created)
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
const msg = err instanceof ApiError ? err.message : String(err)
|
|
const msg = err instanceof ApiError ? err.message : String(err)
|
|
@@ -149,11 +221,16 @@ function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
|
|
|
{/* Admin toggle */}
|
|
{/* Admin toggle */}
|
|
|
<div>
|
|
<div>
|
|
|
<label className="text-xs font-medium text-slate-400 block mb-2">Admin privileges</label>
|
|
<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
|
|
<div
|
|
|
role="switch"
|
|
role="switch"
|
|
|
aria-checked={isAdmin}
|
|
aria-checked={isAdmin}
|
|
|
- onClick={() => setIsAdmin((v) => !v)}
|
|
|
|
|
|
|
+ onClick={handleAdminToggle}
|
|
|
className={[
|
|
className={[
|
|
|
'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors',
|
|
'relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors',
|
|
|
isAdmin ? 'bg-indigo-600' : 'bg-slate-600',
|
|
isAdmin ? 'bg-indigo-600' : 'bg-slate-600',
|
|
@@ -179,6 +256,157 @@ function NewKeyForm({ allProjects, onCreated, onCancel }: NewKeyFormProps) {
|
|
|
<ProjectsPicker allProjects={allProjects} selected={projects} onChange={setProjects} />
|
|
<ProjectsPicker allProjects={allProjects} selected={projects} onChange={setProjects} />
|
|
|
</div>
|
|
</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">
|
|
<div className="flex items-center justify-end gap-3 pt-1">
|
|
|
<button
|
|
<button
|
|
|
type="button"
|
|
type="button"
|
|
@@ -395,7 +623,7 @@ function KeysTable({ keys, allProjects }: KeysTableProps) {
|
|
|
Type
|
|
Type
|
|
|
</th>
|
|
</th>
|
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
|
- Projects
|
|
|
|
|
|
|
+ Scope / Projects
|
|
|
</th>
|
|
</th>
|
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
|
Created
|
|
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 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 font-mono text-slate-400 text-xs">{k.key_prefix}…</td>
|
|
|
<td className="px-4 py-3">
|
|
<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>
|
|
|
<td className="px-4 py-3">
|
|
<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>
|
|
|
<td className="px-4 py-3 text-slate-400 text-xs whitespace-nowrap">
|
|
<td className="px-4 py-3 text-slate-400 text-xs whitespace-nowrap">
|
|
|
{formatDate(k.created_at)}
|
|
{formatDate(k.created_at)}
|