@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/README.md +126 -0
- package/dist/index.mjs +2 -0
- package/dist/react-COCR3YSL.mjs +1603 -0
- package/dist/server.mjs +50 -0
- package/dist/standalone.mjs +11 -0
- package/package.json +47 -0
- package/src/client.ts +278 -0
- package/src/index.ts +43 -0
- package/src/react.tsx +983 -0
- package/src/server.ts +83 -0
- package/src/standalone.tsx +25 -0
- package/src/styles.ts +200 -0
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
|
+
}
|