feat(admin): add fair-use admin dashboard UI#6257
Conversation
Reads from Firestore collection group query on fair_use_state, filterable by stage (warning/throttle/restrict). Closes gap 3 of #6203. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Returns user profile, enforcement state, and violation events. Part of #6203 gap 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resets user enforcement to clean via Firestore Admin SDK. Backend Redis cache (60s TTL) expires naturally. Part of #6203 gap 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Validates stage (none/warning/throttle/restrict) and updates Firestore. Part of #6203 gap 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Marks violation events as resolved with admin audit trail. Part of #6203 gap 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Searches fair_use_events collection group by case_ref (FU-XXXX format). Part of #6203 gap 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Full admin UI for fair-use management: - Flagged users list filterable by stage (warning/throttle/restrict) - Case reference (FU-XXXX) and UID lookup - User detail view with profile, enforcement state, and violation events - Admin actions: reset state, set stage, resolve events - Confirmation dialogs for all destructive actions Closes #6203 gap 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds ShieldAlert icon and link to /dashboard/fair-use. Part of #6203 gap 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds a fair-use enforcement admin dashboard to the existing Next.js admin panel, introducing a main list/detail UI and 6 API routes backed by Firestore Admin SDK. The implementation correctly protects all endpoints with the existing Key findings:
Confidence Score: 4/5Safe to merge after addressing the missing 404 for unknown UIDs; remaining issues are P2 improvements. One P1 issue (silent 200 for unknown UID) could confuse admins and potentially allow mutating actions on dangling Firestore documents. The NaN/limit issue, missing audit field, and re-resolution overwrite are P2 and don't block functionality. The auth model, query logic, and UID path extraction are all sound. web/admin/app/api/omi/fair-use/user/[uid]/route.ts (missing 404), web/admin/app/api/omi/fair-use/flagged/route.ts (NaN limit) Important Files Changed
Sequence DiagramsequenceDiagram
participant Admin as Admin Browser
participant Page as fair-use/page.tsx
participant FlaggedAPI as /api/omi/fair-use/flagged
participant UserAPI as /api/omi/fair-use/user/[uid]
participant CaseAPI as /api/omi/fair-use/case/[caseRef]
participant ResetAPI as /api/omi/fair-use/user/[uid]/reset
participant StageAPI as /api/omi/fair-use/user/[uid]/set-stage
participant ResolveAPI as /api/omi/fair-use/user/[uid]/resolve-event/[eventId]
participant Firestore as Firestore Admin SDK
Admin->>Page: Load Fair Use page
Page->>FlaggedAPI: GET ?stage=... (Bearer token)
FlaggedAPI->>Firestore: collectionGroup('fair_use_state').where(stage IN [...])
Firestore-->>FlaggedAPI: Flagged user docs
FlaggedAPI-->>Page: { users[] }
Admin->>Page: Search by FU-XXXX case ref
Page->>CaseAPI: GET /case/FU-XXXX
CaseAPI->>Firestore: collectionGroup('fair_use_events').where(case_ref == ...)
Firestore-->>CaseAPI: Event doc (uid in path)
CaseAPI-->>Page: { uid, event_id, ... }
Page->>UserAPI: GET /user/{uid}
UserAPI->>Firestore: users/{uid}/fair_use_state/current + fair_use_events + users/{uid}
Firestore-->>UserAPI: state, events, profile
UserAPI-->>Page: { uid, state, events, profile }
Admin->>Page: Reset / Set Stage / Resolve
Page->>ResetAPI: POST /user/{uid}/reset
ResetAPI->>Firestore: set(stage=none, reset_by=adminUid, ...)
Page->>StageAPI: POST /user/{uid}/set-stage?stage=...
StageAPI->>Firestore: set(stage=..., updated_at=now)
Page->>ResolveAPI: POST /user/{uid}/resolve-event/{eventId}
ResolveAPI->>Firestore: update(resolved=true, resolved_by=adminUid, ...)
Reviews (1): Last reviewed commit: "feat(admin): add Fair Use nav item to si..." | Re-trigger Greptile |
| const stateDoc = await db.collection('users').doc(uid).collection('fair_use_state').doc('current').get(); | ||
| const state = stateDoc.exists ? stateDoc.data() : {}; | ||
|
|
||
| // Fetch events (newest first, limit 50) | ||
| const eventsSnapshot = await db | ||
| .collection('users') | ||
| .doc(uid) | ||
| .collection('fair_use_events') | ||
| .orderBy('created_at', 'desc') | ||
| .limit(50) | ||
| .get(); | ||
|
|
||
| const events = eventsSnapshot.docs.map((doc) => { | ||
| const data = doc.data(); | ||
| return { | ||
| ...data, | ||
| id: doc.id, | ||
| created_at: data.created_at?.toDate?.()?.toISOString() || data.created_at, | ||
| resolved_at: data.resolved_at?.toDate?.()?.toISOString() || data.resolved_at, | ||
| }; | ||
| }); | ||
|
|
||
| // Fetch basic user profile | ||
| const userDoc = await db.collection('users').doc(uid).get(); | ||
| const userData = userDoc.exists ? userDoc.data() : {}; |
There was a problem hiding this comment.
Silent 200 for non-existent UID
When neither the fair_use_state doc nor the users doc exists (i.e., an invalid or typo'd UID), this route returns HTTP 200 with empty objects: { uid, state: {}, events: [], profile: { email: '', name: '', subscription_plan: 'basic' } }. The admin UI will render a "User Fair Use Detail" page with entirely blank fields, with no indication the user doesn't exist.
A 404 should be returned when the user has no data at all:
const stateDoc = await db.collection('users').doc(uid).collection('fair_use_state').doc('current').get();
const userDoc = await db.collection('users').doc(uid).get();
if (!stateDoc.exists && !userDoc.exists) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}Without this, an admin searching for a mis-typed UID will silently land on a blank detail view, which is confusing and could lead to mistaken actions (e.g., resetting or setting a stage) on non-existent or wrong documents.
|
|
||
| const { searchParams } = new URL(request.url); | ||
| const stage = searchParams.get('stage'); | ||
| const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 200); |
There was a problem hiding this comment.
parseInt NaN can produce a Firestore 500
If a caller passes ?limit=abc, parseInt('abc', 10) returns NaN. Math.min(NaN, 200) is NaN, and Firestore's .limit(NaN) will throw because it requires a positive integer, surfacing as a 500 to the caller.
| const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 200); | |
| const rawLimit = parseInt(searchParams.get('limit') || '50', 10); | |
| const limit = Math.min(isNaN(rawLimit) || rawLimit <= 0 ? 50 : rawLimit, 200); |
| const updates: Record<string, unknown> = { | ||
| stage, | ||
| updated_at: new Date(), | ||
| }; | ||
|
|
||
| if (stage === 'none') { | ||
| updates.throttle_until = null; | ||
| updates.restrict_until = null; | ||
| } | ||
|
|
||
| await ref.set(updates, { merge: true }); |
There was a problem hiding this comment.
Missing admin-attribution audit field
The reset/route.ts consistently records reset_by: adminUid and reset_at, but this route does not record who changed the stage. For an admin enforcement dashboard, all mutating actions should carry the same audit trail. Consider adding:
const updates: Record<string, unknown> = {
stage,
updated_at: new Date(),
set_stage_by: authResult.uid,
set_stage_at: new Date(),
};| const doc = await ref.get(); | ||
| if (!doc.exists) { | ||
| return NextResponse.json({ error: 'Event not found' }, { status: 404 }); | ||
| } | ||
|
|
||
| await ref.update({ | ||
| resolved: true, | ||
| resolved_at: new Date(), | ||
| resolved_by: adminUid, | ||
| admin_notes: notes, | ||
| }); |
There was a problem hiding this comment.
Re-resolving an event silently overwrites prior resolution metadata
The existence check (!doc.exists → 404) is correct, but there is no guard against re-resolving an already-resolved event. A second POST will overwrite resolved_at, resolved_by, and admin_notes from the original resolution, destroying the audit record of who originally closed the case.
While the UI hides the "Resolve" button for already-resolved events, the API can still be called directly. Consider returning a 409 if data.resolved === true:
const data = doc.data()!;
if (data.resolved) {
return NextResponse.json({ error: 'Event already resolved' }, { status: 409 });
}| const caseData = await res.json(); | ||
| if (caseData.uid) { | ||
| await fetchUserDetail(caseData.uid); | ||
| } |
There was a problem hiding this comment.
Silent no-op when case ref has no associated UID
If the case ref is found in Firestore but the returned document has a falsy uid (e.g., a doc whose path is unexpectedly structured), fetchUserDetail is never called and no error is surfaced. The search spinner clears and the user sees nothing change. A fallback error message would improve debuggability:
if (caseData.uid) {
await fetchUserDetail(caseData.uid);
} else {
setError(`Case ${query} found but has no associated user UID`);
}Events use new_stage/previous_stage (not stage) and nested classifier object (not flat classifier_score/classifier_type). Show stage transition and trigger in events table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Backend stores plan at users/{uid}.subscription.plan, not
users/{uid}.subscription_plan.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Matches backend's invalidate_enforcement_cache() — deletes
fair_use:stage:{uid} key. Fail-open: errors are logged but
do not block admin actions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Immediately clears enforcement cache instead of waiting for 60s TTL expiry, matching backend behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Immediately clears enforcement cache after stage change, matching backend behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Required for immediate enforcement cache clearing on admin fair-use actions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
parseInt on non-numeric input returns NaN which would propagate to Firestore .limit(). Fall back to default 50 when parse fails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All action handlers (fetchUserDetail, handleReset, handleSetStage, handleResolveEvent) now call setError(null) at the start so prior error messages don't persist after successful subsequent actions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Test Results — Fair Use Admin DashboardBuild & Compilation
Auth Enforcement (all 7 API routes)
Validation Tests
Integration Tests (based-hardware-dev, joan SA)
ScreenshotsFirestore IndexCreated collection group index for Total: 20/20 tests passed by AI for @beastoin |
|
lgtm |


Summary
Adds a Fair Use admin dashboard page to web/admin for managing content policy enforcement. Implements 7 API routes for viewing and managing flagged users, with Redis cache invalidation to keep enforcement in sync with the backend.
Closes #6203 (gap 3)
Screenshots
Flagged Users List View

User Detail View

Changed Files
app/(protected)/dashboard/fair-use/page.tsxapp/api/omi/fair-use/flagged/route.tsapp/api/omi/fair-use/user/[uid]/route.tsapp/api/omi/fair-use/user/[uid]/reset/route.tsapp/api/omi/fair-use/user/[uid]/set-stage/route.tsapp/api/omi/fair-use/user/[uid]/resolve-event/[eventId]/route.tsapp/api/omi/fair-use/case/[caseRef]/route.tslib/redis.tscomponents/dashboard/sidebar.tsxpackage.jsonFeatures
Deployment Steps
Pre-deployment (required before merge)
Create Firestore collection group index on
based-hardware(prod):Use the Firebase Console or CLI:
Or create via the Firestore REST API / console link that appears in the error log on first request.
Add Redis secrets to GCP Secret Manager (if not already present):
These are already referenced in
.github/workflows/gcp_admin.yml(lines 86-88).If Redis is not configured, cache invalidation is skipped gracefully (fail-open).
Deployment
Merge this PR — the
gcp_admin.ymlworkflow will automatically:Verify post-deploy:
https://admin.omi.me/dashboard/fair-useRollback
Test Evidence
See test results comment — 20/20 tests passed including:
Review Cycle Fixes
new_stage/previous_stage, nestedclassifier)subscription.plannotsubscription_plan)🤖 Generated with Claude Code