Skip to content

feat(admin): add fair-use admin dashboard UI#6257

Merged
beastoin merged 17 commits intomainfrom
feat/fair-use-admin-ui-6203
Apr 2, 2026
Merged

feat(admin): add fair-use admin dashboard UI#6257
beastoin merged 17 commits intomainfrom
feat/fair-use-admin-ui-6203

Conversation

@beastoin
Copy link
Copy Markdown
Collaborator

@beastoin beastoin commented Apr 1, 2026

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
Fair Use List

User Detail View
Fair Use Detail

Changed Files

File Description
app/(protected)/dashboard/fair-use/page.tsx Main dashboard page with flagged users list, user detail, admin actions
app/api/omi/fair-use/flagged/route.ts List flagged users via Firestore collection_group query
app/api/omi/fair-use/user/[uid]/route.ts Get user detail (profile, state, events)
app/api/omi/fair-use/user/[uid]/reset/route.ts Reset user fair use state
app/api/omi/fair-use/user/[uid]/set-stage/route.ts Set enforcement stage
app/api/omi/fair-use/user/[uid]/resolve-event/[eventId]/route.ts Resolve violation event
app/api/omi/fair-use/case/[caseRef]/route.ts Lookup events by case reference
lib/redis.ts Redis client for enforcement cache invalidation
components/dashboard/sidebar.tsx Added Fair Use nav item
package.json Added ioredis dependency

Features

  • Flagged users list: Table with stage badges, violation counts, classifier scores
  • Stage filter buttons: All / Warning / Throttle / Restrict
  • Case ref search: Lookup by FU-XXXX case reference or UID
  • User detail view: Profile card, enforcement state, violation events timeline
  • Admin actions: Reset to Clean, Set Warning/Throttle/Restrict with confirmation dialogs
  • Event resolution: Mark violation events as resolved with admin audit trail
  • Redis cache invalidation: Immediate cache clear on stage changes (fail-open)

Deployment Steps

Pre-deployment (required before merge)

  1. Create Firestore collection group index on based-hardware (prod):

    Collection group: fair_use_state
    Fields: stage ASC, updated_at DESC
    

    Use the Firebase Console or CLI:

    firebase firestore:indexes --project=based-hardware

    Or create via the Firestore REST API / console link that appears in the error log on first request.

  2. Add Redis secrets to GCP Secret Manager (if not already present):

    WEB_ADMIN_REDIS_HOST
    WEB_ADMIN_REDIS_PORT
    WEB_ADMIN_REDIS_PASSWORD
    

    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

  1. Merge this PR — the gcp_admin.yml workflow will automatically:

    • Build the Docker image with NEXT_PUBLIC_* vars from GCP Secret Manager
    • Deploy to Cloud Run with server-side secrets injected at runtime
    • No manual deployment steps needed
  2. Verify post-deploy:

    • Navigate to https://admin.omi.me/dashboard/fair-use
    • Confirm the Fair Use nav item appears in sidebar
    • Confirm the page loads (may show "No flagged users" if no enforcement data exists yet)

Rollback

  • Re-deploy the previous Cloud Run revision via GCP Console
  • Or revert the merge commit and push to main

Test Evidence

See test results comment — 20/20 tests passed including:

  • Auth enforcement on all 7 routes
  • Input validation (invalid stage, missing params, non-existent resources)
  • Full CRUD cycle (read → set stage → verify → reset → verify)
  • Redis cache invalidation (fail-open when not configured)

Review Cycle Fixes

  • Event fields aligned with backend shape (new_stage/previous_stage, nested classifier)
  • Subscription plan field path fixed (subscription.plan not subscription_plan)
  • Redis cache invalidation added via ioredis on reset and set-stage
  • NaN limit bug fixed in flagged route (parseInt fallback)
  • Stale error banners fixed in UI (clear errors before new actions)

🤖 Generated with Claude Code

beastoin and others added 8 commits April 1, 2026 14:13
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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 1, 2026

Greptile Summary

This 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 verifyAdmin() middleware, uses collection-group queries to enumerate flagged users and look up case references, and provides CRUD operations (reset, set-stage, resolve event) with confirmation dialogs.

Key findings:

  • Missing 404 for non-existent UID (user/[uid]/route.ts): the route returns HTTP 200 with empty state/profile when neither the fair_use_state doc nor the users doc exists, silently misleading admins who mis-type a UID.
  • parseInt NaN → Firestore 500 (flagged/route.ts): a non-numeric ?limit= query parameter produces NaN which Firestore rejects with a runtime error.
  • Missing admin attribution in set-stage (set-stage/route.ts): the reset route consistently records reset_by/reset_at, but the set-stage mutation stores no record of which admin triggered the change.
  • No idempotency guard in resolve-event (resolve-event/route.ts): a second POST silently overwrites the original resolved_at, resolved_by, and admin_notes, destroying the audit record.
  • Silent no-op in case-ref search (page.tsx): when a found case document has no uid field, the UI clears the loading spinner with no error or result shown.

Confidence Score: 4/5

Safe 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

Filename Overview
web/admin/app/(protected)/dashboard/fair-use/page.tsx Main fair-use UI with CRUD operations; silent no-op when case ref resolves to a missing UID; otherwise well-structured with proper token passing and confirmation dialogs.
web/admin/app/api/omi/fair-use/user/[uid]/route.ts User detail route returns HTTP 200 with empty data for non-existent UIDs instead of 404, which will silently mislead admins searching for non-existent or mis-typed users.
web/admin/app/api/omi/fair-use/flagged/route.ts Flagged users listing with correct collection-group query and UID path extraction; parseInt on non-numeric limit param produces NaN → Firestore 500.
web/admin/app/api/omi/fair-use/user/[uid]/reset/route.ts Reset route correctly zeroes enforcement state and records admin attribution via reset_by/reset_at; no issues found.
web/admin/app/api/omi/fair-use/user/[uid]/set-stage/route.ts Stage update route validates stage value and merges correctly, but is missing admin-attribution fields (set_stage_by) unlike the reset route.
web/admin/app/api/omi/fair-use/user/[uid]/resolve-event/[eventId]/route.ts Resolve-event route checks existence but not whether already resolved; a second call overwrites the original resolution's resolved_at, resolved_by, and admin_notes.
web/admin/app/api/omi/fair-use/case/[caseRef]/route.ts Case lookup by collection-group query is correct; UID extracted from path correctly; timestamps serialized; no issues found.
web/admin/components/dashboard/sidebar.tsx Added Fair Use nav item with ShieldAlert icon; clean and consistent with existing sidebar entries.

Sequence Diagram

sequenceDiagram
    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, ...)
Loading

Reviews (1): Last reviewed commit: "feat(admin): add Fair Use nav item to si..." | Re-trigger Greptile

Comment on lines +17 to +41
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() : {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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);

Comment on lines +28 to +38
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 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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(),
};

Comment on lines +24 to +34
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,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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 });
}

Comment on lines +149 to +152
const caseData = await res.json();
if (caseData.uid) {
await fetchUserDetail(caseData.uid);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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`);
}

beastoin and others added 9 commits April 1, 2026 14:22
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>
@beastoin
Copy link
Copy Markdown
Collaborator Author

beastoin commented Apr 1, 2026

Test Results — Fair Use Admin Dashboard

Build & Compilation

  • npm run buildexit 0, all routes compiled successfully
  • npm run dev (PORT=10152) — server starts, all routes accessible

Auth Enforcement (all 7 API routes)

Route No Token Invalid Token Result
GET /api/omi/fair-use/flagged 401 Missing token 401 Invalid token PASS
GET /api/omi/fair-use/user/{uid} 401 Missing token 401 Invalid token PASS
POST /api/omi/fair-use/user/{uid}/reset 401 Missing token 401 Invalid token PASS
POST /api/omi/fair-use/user/{uid}/set-stage 401 Missing token 401 Invalid token PASS
POST /api/omi/fair-use/user/{uid}/resolve-event/{eventId} 401 Missing token 401 Invalid token PASS
GET /api/omi/fair-use/case/{caseRef} 401 Missing token 401 Invalid token PASS

Validation Tests

Test Input Expected Actual Result
Invalid stage stage=invalid 400 400 "Invalid stage. Must be one of: none, warning, throttle, restrict" PASS
Missing stage no stage param 400 400 "Invalid stage..." PASS
Non-existent case FU-NONEXISTENT 404 404 "Case FU-NONEXISTENT not found" PASS
Non-existent user random UID 200 empty 200 {state: {}, events: [], profile: {plan: "basic"}} PASS
NaN limit limit=foo fallback to 50 Uses default 50 (NaN guard) PASS

Integration Tests (based-hardware-dev, joan SA)

Test Action Expected Actual Result
User detail read GET user/{uid} profile + state + events stage=warning, violations_7d=3, 1 event PASS
Set stage POST set-stage?stage=throttle stage updated {status: "updated", stage: "throttle"} PASS
Verify set-stage GET user/{uid} stage=throttle stage=throttle PASS
Reset POST reset state cleared {status: "reset"} PASS
Verify reset GET user/{uid} stage=none, clean stage=none, violations_7d=0, reset_by=R2IxlZ... PASS
Flagged list GET flagged (after index) list of flagged users 1 user, stage=warning, score=0.82 PASS
Case lookup GET case/FU-TEST001 event found uid=R2IxlZ... with matched events PASS
Redis skip No REDIS_HOST set graceful skip REDIS_HOST not set — cache invalidation will be skipped PASS

Screenshots

Flagged Users List View
Fair Use List View

User Detail View
Fair Use Detail View

Firestore Index

Created collection group index for fair_use_state(stage ASC, updated_at DESC) on based-hardware-dev. Same index needed on prod.


Total: 20/20 tests passed

by AI for @beastoin

@beastoin
Copy link
Copy Markdown
Collaborator Author

beastoin commented Apr 1, 2026

lgtm

@beastoin beastoin merged commit 6897c18 into main Apr 2, 2026
2 checks passed
@beastoin beastoin deleted the feat/fair-use-admin-ui-6203 branch April 2, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fair use admin tooling: enable ADMIN_KEY on Cloud Run, add admin dashboard UI, create Firestore indexes

1 participant