@omg-dev/admin 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/react.tsx ADDED
@@ -0,0 +1,983 @@
1
+ // @omg-dev/admin — embeddable admin console (React).
2
+ //
3
+ // <AdminConsole client={...} /> renders the full tabbed console; <FlagsPanel/>
4
+ // and <PricingPanel/> render a single panel if a host wants to place them
5
+ // individually. All three are self-styled (injected <style>, themed from the
6
+ // host's shadcn vars — see styles.ts) and take an AdminClient, so they drop into
7
+ // the omg dashboard, the LFG app, or Inspect without per-host wiring.
8
+ //
9
+ // The console is admin-gated SERVER-side (every endpoint calls requireAdmin),
10
+ // so a non-admin who somehow renders it just sees errors, never data.
11
+
12
+ import { useCallback, useEffect, useMemo, useState, type FormEvent, type ReactElement } from "react"
13
+ import { ensureStyles } from "./styles"
14
+ import {
15
+ AdminClient,
16
+ type AdminClientConfig,
17
+ type FlagDef,
18
+ type FlagOverride,
19
+ type PlanVersion,
20
+ type ModelInfo,
21
+ type UserHit,
22
+ type AdminUser,
23
+ type UserBalance,
24
+ type BugReportSummary,
25
+ type BugReportDetail,
26
+ type BugStatus,
27
+ } from "./client"
28
+
29
+ export type { AdminClientConfig }
30
+ export { AdminClient }
31
+
32
+ // ── primitives ───────────────────────────────────────────────────────────────
33
+
34
+ function Switch({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
35
+ return (
36
+ <label className="vadm-switch">
37
+ <input type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
38
+ <span className="vadm-slider" />
39
+ </label>
40
+ )
41
+ }
42
+
43
+ function ErrorBanner({ error }: { error: unknown }) {
44
+ if (!error) return null
45
+ const msg = error instanceof Error ? error.message : String(error)
46
+ return <div className="vadm-err">{msg}</div>
47
+ }
48
+
49
+ function initialsFor(user: Pick<AdminUser, "name" | "email">): string {
50
+ const source = (user.name || user.email || "?").trim()
51
+ const parts = source.split(/\s+/).filter(Boolean)
52
+ if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
53
+ return source.slice(0, 2).toUpperCase()
54
+ }
55
+
56
+ function UserAvatar({ user }: { user: Pick<AdminUser, "name" | "email" | "image"> }) {
57
+ return (
58
+ <span className="vadm-avatar">
59
+ {user.image ? <img src={user.image} alt="" /> : initialsFor(user)}
60
+ </span>
61
+ )
62
+ }
63
+
64
+ function UserListSkeleton() {
65
+ return (
66
+ <div className="vadm-list" aria-label="Loading users">
67
+ {Array.from({ length: 5 }).map((_, i) => (
68
+ <div key={i} className="vadm-user-row" aria-hidden="true">
69
+ <span className="vadm-skel vadm-skel-avatar" />
70
+ <span className="vadm-grow">
71
+ <span className="vadm-skel" style={{ width: "42%" }} />
72
+ <span className="vadm-skel" style={{ width: "68%", marginTop: 8 }} />
73
+ </span>
74
+ <span className="vadm-skel" style={{ width: 72 }} />
75
+ </div>
76
+ ))}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ // ── Flags panel ──────────────────────────────────────────────────────────────
82
+
83
+ const EMPTY_DRAFT: {
84
+ key: string
85
+ description: string
86
+ valueType: "bool" | "json"
87
+ enabled: boolean
88
+ defaultValue: string
89
+ rolloutPercent: string
90
+ planRules: string
91
+ } = { key: "", description: "", valueType: "bool", enabled: false, defaultValue: "", rolloutPercent: "", planRules: "" }
92
+
93
+ export function FlagsPanel({ client }: { client: AdminClient }) {
94
+ const [flags, setFlags] = useState<FlagDef[] | null>(null)
95
+ const [error, setError] = useState<unknown>(null)
96
+ const [busy, setBusy] = useState(false)
97
+ const [editing, setEditing] = useState<string | null>(null) // key or "__new__"
98
+ const [draft, setDraft] = useState(EMPTY_DRAFT)
99
+ const [expanded, setExpanded] = useState<string | null>(null)
100
+
101
+ const load = useCallback(async () => {
102
+ try {
103
+ setFlags(await client.listFlags())
104
+ setError(null)
105
+ } catch (e) {
106
+ setError(e)
107
+ }
108
+ }, [client])
109
+
110
+ useEffect(() => {
111
+ ensureStyles()
112
+ void load()
113
+ }, [load])
114
+
115
+ function openNew() {
116
+ setDraft(EMPTY_DRAFT)
117
+ setEditing("__new__")
118
+ }
119
+ function openEdit(f: FlagDef) {
120
+ setDraft({
121
+ key: f.key,
122
+ description: f.description,
123
+ valueType: f.valueType,
124
+ enabled: f.enabled,
125
+ defaultValue: f.valueType === "json" ? JSON.stringify(f.defaultValue ?? null, null, 2) : "",
126
+ rolloutPercent: f.rolloutPercent == null ? "" : String(f.rolloutPercent),
127
+ planRules: f.planRules ? JSON.stringify(f.planRules, null, 2) : "",
128
+ })
129
+ setEditing(f.key)
130
+ }
131
+
132
+ async function save() {
133
+ setBusy(true)
134
+ try {
135
+ const payload: Record<string, unknown> = {
136
+ key: draft.key.trim(),
137
+ description: draft.description.trim(),
138
+ valueType: draft.valueType,
139
+ enabled: draft.enabled,
140
+ rolloutPercent: draft.rolloutPercent.trim() === "" ? null : Number(draft.rolloutPercent),
141
+ }
142
+ if (draft.valueType === "json" && draft.defaultValue.trim()) {
143
+ payload.defaultValue = JSON.parse(draft.defaultValue)
144
+ }
145
+ payload.planRules = draft.planRules.trim() ? JSON.parse(draft.planRules) : null
146
+ await client.upsertFlag(payload as never)
147
+ setEditing(null)
148
+ await load()
149
+ } catch (e) {
150
+ setError(e)
151
+ } finally {
152
+ setBusy(false)
153
+ }
154
+ }
155
+
156
+ async function toggleEnabled(f: FlagDef, enabled: boolean) {
157
+ setBusy(true)
158
+ try {
159
+ await client.upsertFlag({
160
+ key: f.key,
161
+ valueType: f.valueType,
162
+ description: f.description,
163
+ enabled,
164
+ rolloutPercent: f.rolloutPercent,
165
+ planRules: f.planRules,
166
+ defaultValue: f.valueType === "json" ? f.defaultValue : undefined,
167
+ })
168
+ await load()
169
+ } catch (e) {
170
+ setError(e)
171
+ } finally {
172
+ setBusy(false)
173
+ }
174
+ }
175
+
176
+ async function archive(f: FlagDef) {
177
+ setBusy(true)
178
+ try {
179
+ await client.setFlagArchived(f.key, !f.archivedAt)
180
+ await load()
181
+ } catch (e) {
182
+ setError(e)
183
+ } finally {
184
+ setBusy(false)
185
+ }
186
+ }
187
+
188
+ if (editing) {
189
+ const isNew = editing === "__new__"
190
+ return (
191
+ <div>
192
+ <p className="vadm-h">{isNew ? "New flag" : `Edit ${draft.key}`}</p>
193
+ <ErrorBanner error={error} />
194
+ <div className="vadm-field">
195
+ <label className="vadm-label">Key</label>
196
+ <input
197
+ className="vadm-input vadm-mono"
198
+ placeholder="model.claude-opus-4-8"
199
+ value={draft.key}
200
+ disabled={!isNew}
201
+ onChange={(e) => setDraft({ ...draft, key: e.target.value })}
202
+ />
203
+ </div>
204
+ <div className="vadm-field">
205
+ <label className="vadm-label">Description</label>
206
+ <input className="vadm-input" value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
207
+ </div>
208
+ <div className="vadm-row" style={{ marginBottom: 10 }}>
209
+ <div className="vadm-grow">
210
+ <label className="vadm-label">Type</label>
211
+ <select className="vadm-select" value={draft.valueType} disabled={!isNew} onChange={(e) => setDraft({ ...draft, valueType: e.target.value as "bool" | "json" })}>
212
+ <option value="bool">boolean</option>
213
+ <option value="json">json</option>
214
+ </select>
215
+ </div>
216
+ <div className="vadm-grow">
217
+ <label className="vadm-label">Rollout %</label>
218
+ <input className="vadm-input" placeholder="(none)" inputMode="numeric" value={draft.rolloutPercent} onChange={(e) => setDraft({ ...draft, rolloutPercent: e.target.value })} />
219
+ </div>
220
+ <div>
221
+ <label className="vadm-label">Default on</label>
222
+ <Switch checked={draft.enabled} onChange={(v) => setDraft({ ...draft, enabled: v })} />
223
+ </div>
224
+ </div>
225
+ {draft.valueType === "json" && (
226
+ <div className="vadm-field">
227
+ <label className="vadm-label">Default value (JSON)</label>
228
+ <textarea className="vadm-textarea" value={draft.defaultValue} onChange={(e) => setDraft({ ...draft, defaultValue: e.target.value })} />
229
+ </div>
230
+ )}
231
+ <div className="vadm-field">
232
+ <label className="vadm-label">Plan rules (JSON map, optional) — e.g. {`{"free":false,"pro":true}`}</label>
233
+ <textarea className="vadm-textarea" value={draft.planRules} onChange={(e) => setDraft({ ...draft, planRules: e.target.value })} />
234
+ </div>
235
+ <div className="vadm-row">
236
+ <button className="vadm-btn" data-primary="true" disabled={busy || !draft.key.trim()} onClick={save}>Save</button>
237
+ <button className="vadm-btn" disabled={busy} onClick={() => { setEditing(null); setError(null) }}>Cancel</button>
238
+ </div>
239
+ </div>
240
+ )
241
+ }
242
+
243
+ return (
244
+ <div>
245
+ <div className="vadm-spread" style={{ marginBottom: 12 }}>
246
+ <p className="vadm-sub" style={{ margin: 0 }}>Gate UI / API features per account, plan, or rollout %.</p>
247
+ <button className="vadm-btn" data-primary="true" onClick={openNew}>New flag</button>
248
+ </div>
249
+ <ErrorBanner error={error} />
250
+ {flags === null ? (
251
+ <div className="vadm-empty">Loading…</div>
252
+ ) : flags.length === 0 ? (
253
+ <div className="vadm-empty">No flags yet.</div>
254
+ ) : (
255
+ flags.map((f) => (
256
+ <div key={f.key} className="vadm-card" data-off={f.archivedAt ? "true" : undefined}>
257
+ <div className="vadm-spread">
258
+ <div className="vadm-grow">
259
+ <div className="vadm-row">
260
+ <span className="vadm-mono" style={{ fontWeight: 600 }}>{f.key}</span>
261
+ <span className="vadm-badge">{f.valueType}</span>
262
+ {f.rolloutPercent != null && <span className="vadm-badge">{f.rolloutPercent}%</span>}
263
+ {f.planRules && <span className="vadm-badge">plan rules</span>}
264
+ {f.archivedAt && <span className="vadm-badge">archived</span>}
265
+ </div>
266
+ {f.description && <div className="vadm-muted" style={{ fontSize: 13, marginTop: 2 }}>{f.description}</div>}
267
+ </div>
268
+ <div className="vadm-row">
269
+ {f.valueType === "bool" && !f.archivedAt && (
270
+ <Switch checked={f.enabled} disabled={busy} onChange={(v) => toggleEnabled(f, v)} />
271
+ )}
272
+ <button className="vadm-btn vadm-btn-sm" onClick={() => setExpanded(expanded === f.key ? null : f.key)}>
273
+ {f.overrideCount > 0 ? `${f.overrideCount} override${f.overrideCount > 1 ? "s" : ""}` : "Overrides"}
274
+ </button>
275
+ <button className="vadm-btn vadm-btn-sm" onClick={() => openEdit(f)}>Edit</button>
276
+ <button className="vadm-btn vadm-btn-sm" onClick={() => archive(f)}>{f.archivedAt ? "Restore" : "Archive"}</button>
277
+ </div>
278
+ </div>
279
+ {expanded === f.key && <OverridesEditor client={client} flag={f} onChange={load} />}
280
+ </div>
281
+ ))
282
+ )}
283
+ </div>
284
+ )
285
+ }
286
+
287
+ // ── per-account overrides ──────────────────────────────────────────────────
288
+
289
+ function OverridesEditor({ client, flag, onChange }: { client: AdminClient; flag: FlagDef; onChange: () => void }) {
290
+ const [rows, setRows] = useState<FlagOverride[] | null>(null)
291
+ const [error, setError] = useState<unknown>(null)
292
+ const [query, setQuery] = useState("")
293
+ const [hits, setHits] = useState<UserHit[]>([])
294
+ const [picked, setPicked] = useState<UserHit | null>(null)
295
+ const [enabled, setEnabled] = useState(true)
296
+ const [value, setValue] = useState("")
297
+ const [busy, setBusy] = useState(false)
298
+
299
+ const reload = useCallback(async () => {
300
+ try {
301
+ setRows(await client.listOverrides(flag.key))
302
+ } catch (e) {
303
+ setError(e)
304
+ }
305
+ }, [client, flag.key])
306
+
307
+ useEffect(() => { void reload() }, [reload])
308
+
309
+ useEffect(() => {
310
+ if (!query.trim() || picked) { setHits([]); return }
311
+ let live = true
312
+ const t = setTimeout(async () => {
313
+ try {
314
+ const r = await client.findUsers(query.trim())
315
+ if (live) setHits(r)
316
+ } catch { /* ignore search errors */ }
317
+ }, 200)
318
+ return () => { live = false; clearTimeout(t) }
319
+ }, [query, picked, client])
320
+
321
+ async function add() {
322
+ if (!picked) return
323
+ setBusy(true)
324
+ try {
325
+ await client.setOverride({
326
+ flagKey: flag.key,
327
+ userId: picked.id,
328
+ enabled,
329
+ value: flag.valueType === "json" && value.trim() ? JSON.parse(value) : undefined,
330
+ })
331
+ setPicked(null); setQuery(""); setValue(""); setEnabled(true)
332
+ await reload(); onChange()
333
+ } catch (e) {
334
+ setError(e)
335
+ } finally {
336
+ setBusy(false)
337
+ }
338
+ }
339
+
340
+ async function drop(userId: string) {
341
+ setBusy(true)
342
+ try {
343
+ await client.removeOverride(flag.key, userId)
344
+ await reload(); onChange()
345
+ } catch (e) {
346
+ setError(e)
347
+ } finally {
348
+ setBusy(false)
349
+ }
350
+ }
351
+
352
+ return (
353
+ <div className="vadm-detail">
354
+ <ErrorBanner error={error} />
355
+ {rows && rows.length > 0 && (
356
+ <table className="vadm-table" style={{ marginBottom: 12 }}>
357
+ <thead><tr><th>Account</th><th>State</th>{flag.valueType === "json" && <th>Value</th>}<th /></tr></thead>
358
+ <tbody>
359
+ {rows.map((r) => (
360
+ <tr key={r.userId}>
361
+ <td>{r.userEmail ?? r.userId}</td>
362
+ <td><span className="vadm-badge" data-on={r.enabled ? "true" : undefined}>{r.enabled ? "on" : "off"}</span></td>
363
+ {flag.valueType === "json" && <td className="vadm-mono">{r.value == null ? "—" : JSON.stringify(r.value)}</td>}
364
+ <td style={{ textAlign: "right" }}><button className="vadm-btn vadm-btn-sm" data-danger="true" disabled={busy} onClick={() => drop(r.userId)}>Remove</button></td>
365
+ </tr>
366
+ ))}
367
+ </tbody>
368
+ </table>
369
+ )}
370
+ <div className="vadm-row" style={{ position: "relative" }}>
371
+ <div className="vadm-grow" style={{ position: "relative" }}>
372
+ <input
373
+ className="vadm-input"
374
+ placeholder="Search account by email…"
375
+ value={picked ? `${picked.email}` : query}
376
+ onChange={(e) => { setPicked(null); setQuery(e.target.value) }}
377
+ />
378
+ {hits.length > 0 && (
379
+ <div className="vadm-card" style={{ position: "absolute", zIndex: 5, left: 0, right: 0, marginTop: 2, padding: 4 }}>
380
+ {hits.map((h) => (
381
+ <div key={h.id} className="vadm-btn" style={{ display: "block", border: "none", textAlign: "left", width: "100%" }} onClick={() => { setPicked(h); setHits([]) }}>
382
+ {h.email}
383
+ </div>
384
+ ))}
385
+ </div>
386
+ )}
387
+ </div>
388
+ <Switch checked={enabled} onChange={setEnabled} />
389
+ {flag.valueType === "json" && (
390
+ <input className="vadm-input vadm-mono vadm-grow" placeholder='value JSON (optional)' value={value} onChange={(e) => setValue(e.target.value)} />
391
+ )}
392
+ <button className="vadm-btn vadm-btn-sm" data-primary="true" disabled={!picked || busy} onClick={add}>Add</button>
393
+ </div>
394
+ </div>
395
+ )
396
+ }
397
+
398
+ // ── Pricing panel (read-only) ──────────────────────────────────────────────
399
+
400
+ export function PricingPanel({ client }: { client: AdminClient }) {
401
+ const [plans, setPlans] = useState<PlanVersion[] | null>(null)
402
+ const [models, setModels] = useState<ModelInfo[] | null>(null)
403
+ const [error, setError] = useState<unknown>(null)
404
+
405
+ useEffect(() => {
406
+ ensureStyles()
407
+ void (async () => {
408
+ try {
409
+ const [p, m] = await Promise.all([client.planVersions(), client.models()])
410
+ setPlans(p); setModels(m)
411
+ } catch (e) {
412
+ setError(e)
413
+ }
414
+ })()
415
+ }, [client])
416
+
417
+ return (
418
+ <div>
419
+ <p className="vadm-sub">Read-only — pricing is code-defined (defineBilling) and reconciled by the orchestrator.</p>
420
+ <ErrorBanner error={error} />
421
+ <p className="vadm-h">Plans</p>
422
+ {plans === null ? <div className="vadm-empty">Loading…</div> : plans.length === 0 ? <div className="vadm-empty">No plan versions.</div> : (
423
+ <table className="vadm-table" style={{ marginBottom: 24 }}>
424
+ <thead><tr><th>Plan</th><th>Ver</th><th>Status</th><th>Price</th><th>Interval</th><th>Stripe price</th></tr></thead>
425
+ <tbody>
426
+ {plans.map((p) => (
427
+ <tr key={`${p.planKey}-${p.version}`}>
428
+ <td style={{ fontWeight: 600 }}>{p.planKey}</td>
429
+ <td>{p.version}</td>
430
+ <td><span className="vadm-badge" data-on={p.status === "active" ? "true" : undefined}>{p.status}</span></td>
431
+ <td>${p.priceUsd.toFixed(2)}</td>
432
+ <td>{p.interval}</td>
433
+ <td className="vadm-mono">{p.externalPriceId || "—"}</td>
434
+ </tr>
435
+ ))}
436
+ </tbody>
437
+ </table>
438
+ )}
439
+ <p className="vadm-h">Models</p>
440
+ {models === null ? <div className="vadm-empty">Loading…</div> : models.length === 0 ? <div className="vadm-empty">No models.</div> : (
441
+ <table className="vadm-table">
442
+ <thead><tr><th>Model</th><th>Provider</th><th>ID</th></tr></thead>
443
+ <tbody>
444
+ {models.map((m) => (
445
+ <tr key={m.id}>
446
+ <td>{m.name ?? m.id}</td>
447
+ <td>{m.provider ?? "—"}</td>
448
+ <td className="vadm-mono">{m.id}</td>
449
+ </tr>
450
+ ))}
451
+ </tbody>
452
+ </table>
453
+ )}
454
+ </div>
455
+ )
456
+ }
457
+
458
+ // ── Users panel ────────────────────────────────────────────────────────────
459
+
460
+ export function UsersPanel({ client }: { client: AdminClient }) {
461
+ const [queryInput, setQueryInput] = useState("")
462
+ const [query, setQuery] = useState("")
463
+ const [users, setUsers] = useState<AdminUser[] | null>(null)
464
+ const [total, setTotal] = useState(0)
465
+ const [error, setError] = useState<unknown>(null)
466
+ const [selectedId, setSelectedId] = useState<string | null>(null)
467
+ const [balance, setBalance] = useState<UserBalance | null>(null)
468
+ const [balanceError, setBalanceError] = useState<unknown>(null)
469
+
470
+ const load = useCallback(async () => {
471
+ try {
472
+ const res = await client.listUsers({ query, limit: 50 })
473
+ setUsers(res.users)
474
+ setTotal(res.total)
475
+ setSelectedId((current) => current ?? res.users[0]?.id ?? null)
476
+ setError(null)
477
+ } catch (e) {
478
+ setError(e)
479
+ }
480
+ }, [client, query])
481
+
482
+ useEffect(() => {
483
+ ensureStyles()
484
+ void load()
485
+ }, [load])
486
+
487
+ const selected = users?.find((u) => u.id === selectedId) ?? users?.[0] ?? null
488
+
489
+ useEffect(() => {
490
+ if (!selected) {
491
+ setBalance(null)
492
+ setBalanceError(null)
493
+ return
494
+ }
495
+ let live = true
496
+ setBalance(null)
497
+ setBalanceError(null)
498
+ void client.userBalance(selected.id)
499
+ .then((b) => { if (live) setBalance(b) })
500
+ .catch((e) => { if (live) setBalanceError(e) })
501
+ return () => { live = false }
502
+ }, [client, selected?.id])
503
+
504
+ function submit(e: FormEvent) {
505
+ e.preventDefault()
506
+ setQuery(queryInput.trim())
507
+ setSelectedId(null)
508
+ setUsers(null)
509
+ }
510
+
511
+ return (
512
+ <div>
513
+ <div className="vadm-spread vadm-panel-head">
514
+ <div>
515
+ <p className="vadm-h">Users</p>
516
+ <p className="vadm-sub">Accounts, sessions, projects, flag overrides, and billing status.</p>
517
+ </div>
518
+ <form className="vadm-search" onSubmit={submit}>
519
+ <input
520
+ className="vadm-input"
521
+ placeholder="Search users"
522
+ value={queryInput}
523
+ onChange={(e) => setQueryInput(e.target.value)}
524
+ />
525
+ <button className="vadm-btn" type="submit">Search</button>
526
+ {query && (
527
+ <button
528
+ className="vadm-btn"
529
+ type="button"
530
+ onClick={() => {
531
+ setQuery("")
532
+ setQueryInput("")
533
+ setSelectedId(null)
534
+ setUsers(null)
535
+ }}
536
+ >
537
+ Clear
538
+ </button>
539
+ )}
540
+ </form>
541
+ </div>
542
+
543
+ <ErrorBanner error={error} />
544
+
545
+ {users === null ? (
546
+ <UserListSkeleton />
547
+ ) : users.length === 0 ? (
548
+ <div className="vadm-empty">No users found.</div>
549
+ ) : (
550
+ <div className="vadm-split">
551
+ <div className="vadm-list">
552
+ <div className="vadm-muted" style={{ fontSize: 12, marginBottom: 8 }}>
553
+ Showing {users.length} of {total}
554
+ </div>
555
+ {users.map((u) => (
556
+ <button
557
+ key={u.id}
558
+ className="vadm-user-row"
559
+ data-active={u.id === selected?.id}
560
+ onClick={() => setSelectedId(u.id)}
561
+ >
562
+ <UserAvatar user={u} />
563
+ <span className="vadm-grow">
564
+ <span className="vadm-user-name">{u.name || u.email}</span>
565
+ <span className="vadm-muted">{u.email}</span>
566
+ </span>
567
+ <span className="vadm-user-stats">
568
+ <span>{u.projectCount} apps</span>
569
+ <span>{u.activeSessionCount} sessions</span>
570
+ </span>
571
+ </button>
572
+ ))}
573
+ </div>
574
+
575
+ {selected && (
576
+ <div className="vadm-card vadm-inspector">
577
+ <div className="vadm-row" style={{ alignItems: "flex-start" }}>
578
+ <UserAvatar user={selected} />
579
+ <div className="vadm-grow">
580
+ <p className="vadm-h" style={{ marginBottom: 2 }}>{selected.name || selected.email}</p>
581
+ <div className="vadm-muted">{selected.email}</div>
582
+ </div>
583
+ <span className="vadm-badge" data-on={selected.emailVerified ? "true" : undefined}>
584
+ {selected.emailVerified ? "verified" : "unverified"}
585
+ </span>
586
+ </div>
587
+
588
+ <div className="vadm-metrics">
589
+ <div><strong>{selected.projectCount}</strong><span>apps</span></div>
590
+ <div><strong>{selected.ownedProjectCount}</strong><span>owned</span></div>
591
+ <div><strong>{selected.activeSessionCount}</strong><span>sessions</span></div>
592
+ <div><strong>{selected.flagOverrideCount}</strong><span>flags</span></div>
593
+ </div>
594
+
595
+ <div className="vadm-kv">
596
+ <div><span>User ID</span><code>{selected.id}</code></div>
597
+ <div><span>Joined</span><strong>{fmtTime(selected.createdAt)}</strong></div>
598
+ <div><span>Updated</span><strong>{fmtTime(selected.updatedAt)}</strong></div>
599
+ <div>
600
+ <span>Plan</span>
601
+ <strong>
602
+ {balance ? balance.plan : balanceError ? "Unavailable" : "Loading..."}
603
+ </strong>
604
+ </div>
605
+ {balance && (
606
+ <>
607
+ <div><span>Balance</span><strong>${balance.balanceUsd.toFixed(2)}</strong></div>
608
+ <div><span>App credits</span><strong>${balance.appCreditsUsd.toFixed(2)}</strong></div>
609
+ </>
610
+ )}
611
+ </div>
612
+ <ErrorBanner error={balanceError} />
613
+ </div>
614
+ )}
615
+ </div>
616
+ )}
617
+ </div>
618
+ )
619
+ }
620
+
621
+ // ── Reports panel (triage) ─────────────────────────────────────────────────
622
+
623
+ const BUG_STATUSES: BugStatus[] = ["open", "triaged", "in_progress", "resolved", "wont_fix"]
624
+ const STATUS_LABEL: Record<BugStatus, string> = {
625
+ open: "Open",
626
+ triaged: "Triaged",
627
+ in_progress: "In progress",
628
+ resolved: "Resolved",
629
+ wont_fix: "Won't fix",
630
+ }
631
+ const OPEN_STATUSES = new Set<BugStatus>(["open", "triaged", "in_progress"])
632
+
633
+ function fmtTime(ms: number): string {
634
+ try {
635
+ return new Date(ms).toLocaleString()
636
+ } catch {
637
+ return String(ms)
638
+ }
639
+ }
640
+
641
+ // Bug-report URLs (pageUrl, screenshotUrl) are UNTRUSTED visitor input. Only let
642
+ // http(s) reach an href/src — a stored "javascript:" / "data:" URL would be XSS
643
+ // against the admin who clicks it. Returns undefined for anything else, so we
644
+ // render plain text instead of a live link.
645
+ function safeHref(v: unknown): string | undefined {
646
+ if (typeof v !== "string") return undefined
647
+ try {
648
+ const u = new URL(v)
649
+ return u.protocol === "http:" || u.protocol === "https:" ? v : undefined
650
+ } catch {
651
+ return undefined
652
+ }
653
+ }
654
+
655
+ // Screenshots come from a report (untrusted). An <img src> to an arbitrary host
656
+ // is a tracking pixel / IP-leak / cookie beacon against the admin. Only render
657
+ // images served from our storage CDN; otherwise show nothing (the report still
658
+ // carries the durable screenshotKey for manual lookup). Mirrors the ingestion
659
+ // allowlist in control-plane/lib/sanitize.ts → storageImageUrl.
660
+ const STORAGE_HOSTS = ["t3.storage.dev"]
661
+ function safeImageSrc(v: unknown): string | undefined {
662
+ if (typeof v !== "string") return undefined
663
+ try {
664
+ const u = new URL(v)
665
+ if (u.protocol !== "http:" && u.protocol !== "https:") return undefined
666
+ const host = u.hostname.toLowerCase()
667
+ return STORAGE_HOSTS.some((h) => host === h || host.endsWith("." + h)) ? v : undefined
668
+ } catch {
669
+ return undefined
670
+ }
671
+ }
672
+
673
+ export function ReportsPanel({ client }: { client: AdminClient }) {
674
+ const [reports, setReports] = useState<BugReportSummary[] | null>(null)
675
+ const [counts, setCounts] = useState<Record<string, number>>({})
676
+ const [filter, setFilter] = useState<BugStatus | "all">("all")
677
+ const [selectedId, setSelectedId] = useState<string | null>(null)
678
+ const [detail, setDetail] = useState<BugReportDetail | null>(null)
679
+ const [error, setError] = useState<unknown>(null)
680
+ const [busy, setBusy] = useState(false)
681
+ const [note, setNote] = useState("")
682
+
683
+ const load = useCallback(async () => {
684
+ try {
685
+ const res = await client.listReports(filter === "all" ? {} : { status: filter })
686
+ setReports(res.reports)
687
+ setCounts(res.counts)
688
+ setError(null)
689
+ } catch (e) {
690
+ setError(e)
691
+ }
692
+ }, [client, filter])
693
+
694
+ useEffect(() => {
695
+ ensureStyles()
696
+ void load()
697
+ }, [load])
698
+
699
+ const openDetail = useCallback(
700
+ async (id: string) => {
701
+ setSelectedId(id)
702
+ setDetail(null)
703
+ try {
704
+ const d = await client.getReport(id)
705
+ setDetail(d)
706
+ setNote(d?.adminNotes ?? "")
707
+ } catch (e) {
708
+ setError(e)
709
+ }
710
+ },
711
+ [client],
712
+ )
713
+
714
+ async function mutate(fn: () => Promise<unknown>) {
715
+ setBusy(true)
716
+ try {
717
+ await fn()
718
+ await load()
719
+ if (selectedId) await openDetail(selectedId)
720
+ setError(null)
721
+ } catch (e) {
722
+ setError(e)
723
+ } finally {
724
+ setBusy(false)
725
+ }
726
+ }
727
+
728
+ const totalOpen = BUG_STATUSES.filter((s) => OPEN_STATUSES.has(s)).reduce((n, s) => n + (counts[s] ?? 0), 0)
729
+
730
+ return (
731
+ <div>
732
+ <p className="vadm-sub">
733
+ User-submitted bug reports. Each carries the context to <strong>recover</strong> — open or fork the exact app
734
+ version the reporter saw. {totalOpen} open.
735
+ </p>
736
+ <ErrorBanner error={error} />
737
+
738
+ <div className="vadm-row" style={{ marginBottom: 12, gap: 6, flexWrap: "wrap" }}>
739
+ <button className="vadm-tab" data-active={filter === "all"} onClick={() => setFilter("all")}>
740
+ All
741
+ </button>
742
+ {BUG_STATUSES.map((s) => (
743
+ <button key={s} className="vadm-tab" data-active={filter === s} onClick={() => setFilter(s)}>
744
+ {STATUS_LABEL[s]} {counts[s] ? `(${counts[s]})` : ""}
745
+ </button>
746
+ ))}
747
+ </div>
748
+
749
+ <div className="vadm-spread" style={{ alignItems: "flex-start", gap: 16 }}>
750
+ {/* List */}
751
+ <div className="vadm-grow" style={{ minWidth: 0 }}>
752
+ {reports === null ? (
753
+ <div className="vadm-empty">Loading…</div>
754
+ ) : reports.length === 0 ? (
755
+ <div className="vadm-empty">No reports.</div>
756
+ ) : (
757
+ <table className="vadm-table">
758
+ <thead>
759
+ <tr>
760
+ <th>Title</th>
761
+ <th>Sev</th>
762
+ <th>Status</th>
763
+ <th>Reporter</th>
764
+ <th>When</th>
765
+ </tr>
766
+ </thead>
767
+ <tbody>
768
+ {reports.map((r) => (
769
+ <tr
770
+ key={r.id}
771
+ onClick={() => openDetail(r.id)}
772
+ style={{ cursor: "pointer", fontWeight: r.id === selectedId ? 600 : undefined }}
773
+ >
774
+ <td style={{ maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
775
+ {r.title}
776
+ </td>
777
+ <td>
778
+ <span className="vadm-badge" data-on={r.severity === "critical" || r.severity === "high" ? "true" : undefined}>
779
+ {r.severity}
780
+ </span>
781
+ </td>
782
+ <td>{STATUS_LABEL[r.status]}</td>
783
+ <td className="vadm-muted">
784
+ {r.reporterEmail ?? "—"}
785
+ {r.reporterEmail && !r.reporterUserId && (
786
+ <span className="vadm-badge" style={{ marginLeft: 6 }} title="Self-reported email — not verified">
787
+ unverified
788
+ </span>
789
+ )}
790
+ </td>
791
+ <td className="vadm-muted">{fmtTime(r.createdAt)}</td>
792
+ </tr>
793
+ ))}
794
+ </tbody>
795
+ </table>
796
+ )}
797
+ </div>
798
+
799
+ {/* Detail */}
800
+ {detail && (
801
+ <div className="vadm-detail vadm-card" style={{ width: 360, flexShrink: 0 }}>
802
+ {/* The title + body are UNTRUSTED visitor text (potential prompt
803
+ injection if pasted into an LLM). Fence them visually so a
804
+ triager never mistakes embedded "instructions" for system copy.
805
+ React already escapes the content; this is provenance, not XSS. */}
806
+ <div
807
+ style={{
808
+ border: "1px solid #f0d8a8",
809
+ background: "#fffaf0",
810
+ borderRadius: 8,
811
+ padding: "8px 10px",
812
+ margin: "0 0 12px",
813
+ }}
814
+ >
815
+ <div className="vadm-muted" style={{ fontSize: 11, marginBottom: 4, textTransform: "uppercase", letterSpacing: 0.4 }}>
816
+ Reported text · untrusted input
817
+ </div>
818
+ <p className="vadm-h" style={{ margin: "0 0 4px" }}>{detail.title}</p>
819
+ <p style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", margin: 0 }}>
820
+ {detail.body || <em className="vadm-muted">No description.</em>}
821
+ </p>
822
+ </div>
823
+
824
+ <div className="vadm-field">
825
+ <label className="vadm-label">Status</label>
826
+ <select
827
+ className="vadm-select"
828
+ value={detail.status}
829
+ disabled={busy}
830
+ onChange={(e) => mutate(() => client.setReportStatus(detail.id, e.target.value as BugStatus))}
831
+ >
832
+ {BUG_STATUSES.map((s) => (
833
+ <option key={s} value={s}>{STATUS_LABEL[s]}</option>
834
+ ))}
835
+ </select>
836
+ </div>
837
+
838
+ <div className="vadm-field">
839
+ <label className="vadm-label">Recover context</label>
840
+ <div className="vadm-mono" style={{ fontSize: 12, lineHeight: 1.6 }}>
841
+ {detail.slug ? (
842
+ <div>
843
+ app:{" "}
844
+ <a href={`https://omg.dev/${detail.slug}`} target="_blank" rel="noreferrer">{detail.slug}</a>
845
+ {detail.version != null ? ` · v${detail.version}` : ""}
846
+ </div>
847
+ ) : (
848
+ <div className="vadm-muted">no app linked</div>
849
+ )}
850
+ {detail.snapshotId && <div>snapshot: {detail.snapshotId}</div>}
851
+ {detail.runId && <div>run: {detail.runId}</div>}
852
+ {detail.pageUrl && (
853
+ <div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
854
+ page:{" "}
855
+ {safeHref(detail.pageUrl) ? (
856
+ <a href={detail.pageUrl} target="_blank" rel="noreferrer">{detail.pageUrl}</a>
857
+ ) : (
858
+ <span>{detail.pageUrl}</span>
859
+ )}
860
+ </div>
861
+ )}
862
+ </div>
863
+ </div>
864
+
865
+ {detail.slug && (
866
+ <div className="vadm-row" style={{ gap: 6, marginBottom: 8 }}>
867
+ <a className="vadm-btn vadm-btn-sm" href={`https://omg.dev/${detail.slug}`} target="_blank" rel="noreferrer">
868
+ Open app
869
+ </a>
870
+ <a className="vadm-btn vadm-btn-sm" href={`https://omg.dev/${detail.slug}/inspect/data`} target="_blank" rel="noreferrer">
871
+ Inspect
872
+ </a>
873
+ </div>
874
+ )}
875
+
876
+ {(() => {
877
+ const ctx = detail.context as { screenshotUrl?: unknown } | null
878
+ const url = safeImageSrc(ctx?.screenshotUrl) ?? null
879
+ return url ? (
880
+ <div className="vadm-field">
881
+ <label className="vadm-label">Screenshot</label>
882
+ <a href={url} target="_blank" rel="noreferrer">
883
+ <img
884
+ src={url}
885
+ alt="screenshot"
886
+ style={{ width: "100%", maxHeight: 200, objectFit: "cover", borderRadius: 8, border: "1px solid #eee" }}
887
+ />
888
+ </a>
889
+ </div>
890
+ ) : null
891
+ })()}
892
+
893
+ {detail.context != null && (
894
+ <div className="vadm-field">
895
+ <label className="vadm-label">Diagnostics</label>
896
+ <pre className="vadm-mono" style={{ fontSize: 11, maxHeight: 160, overflow: "auto", margin: 0 }}>
897
+ {JSON.stringify(detail.context, null, 2)}
898
+ </pre>
899
+ </div>
900
+ )}
901
+
902
+ <div className="vadm-field">
903
+ <label className="vadm-label">Triage note</label>
904
+ <textarea className="vadm-textarea" rows={3} value={note} onChange={(e) => setNote(e.target.value)} disabled={busy} />
905
+ <button
906
+ className="vadm-btn vadm-btn-sm"
907
+ disabled={busy || note === (detail.adminNotes ?? "")}
908
+ onClick={() => mutate(() => client.addReportNote(detail.id, note))}
909
+ style={{ marginTop: 6 }}
910
+ >
911
+ Save note
912
+ </button>
913
+ </div>
914
+
915
+ <div className="vadm-spread" style={{ marginTop: 12 }}>
916
+ <span className="vadm-muted" style={{ fontSize: 12 }}>
917
+ {detail.reporterEmail ?? "anonymous"}
918
+ {detail.reporterEmail && !detail.reporterUserId && " · unverified"}
919
+ </span>
920
+ <button
921
+ className="vadm-btn vadm-btn-sm"
922
+ disabled={busy}
923
+ onClick={() => {
924
+ if (confirm("Delete this report?")) void mutate(() => client.removeReport(detail.id).then(() => { setDetail(null); setSelectedId(null) }))
925
+ }}
926
+ >
927
+ Delete
928
+ </button>
929
+ </div>
930
+ </div>
931
+ )}
932
+ </div>
933
+ </div>
934
+ )
935
+ }
936
+
937
+ // ── Console (tab shell) ──────────────────────────────────────────────────────
938
+
939
+ export interface PanelDef {
940
+ id: string
941
+ label: string
942
+ render: (client: AdminClient) => ReactElement
943
+ }
944
+
945
+ const DEFAULT_PANELS: PanelDef[] = [
946
+ { id: "flags", label: "Flags", render: (c) => <FlagsPanel client={c} /> },
947
+ { id: "users", label: "Users", render: (c) => <UsersPanel client={c} /> },
948
+ { id: "reports", label: "Reports", render: (c) => <ReportsPanel client={c} /> },
949
+ { id: "pricing", label: "Pricing", render: (c) => <PricingPanel client={c} /> },
950
+ ]
951
+
952
+ export interface AdminConsoleProps {
953
+ /** A constructed AdminClient, or a config to build one from. */
954
+ client?: AdminClient
955
+ config?: AdminClientConfig
956
+ /** Override the panel set (e.g. embed only Flags). Defaults to Flags + Pricing. */
957
+ panels?: PanelDef[]
958
+ }
959
+
960
+ export function AdminConsole({ client, config, panels = DEFAULT_PANELS }: AdminConsoleProps) {
961
+ const resolved = useMemo(() => client ?? (config ? new AdminClient(config) : null), [client, config])
962
+ const [active, setActive] = useState(panels[0]?.id)
963
+
964
+ useEffect(() => { ensureStyles() }, [])
965
+
966
+ if (!resolved) {
967
+ return <div className="vadm"><div className="vadm-err">AdminConsole requires a `client` or `config` prop.</div></div>
968
+ }
969
+ const current = panels.find((p) => p.id === active) ?? panels[0]
970
+
971
+ return (
972
+ <div className="vadm">
973
+ <div className="vadm-tabs">
974
+ {panels.map((p) => (
975
+ <button key={p.id} className="vadm-tab" data-active={p.id === current?.id} onClick={() => setActive(p.id)}>
976
+ {p.label}
977
+ </button>
978
+ ))}
979
+ </div>
980
+ {current?.render(resolved)}
981
+ </div>
982
+ )
983
+ }