Admin UI for offer review, user management, and platform oversight
The Admin Dashboard is a set of pages under /admin/* in zooly-app that gives administrators the ability to:
All admin pages require the admin role. Unauthorized users see a 403 page.
Layout: apps/zooly-app/app/(pages)/admin/layout.tsx
The admin layout wraps all /admin/* pages and provides:
GET /api/offers/admin/queue?limit=1 — if the response is not 403, the user is authorized. Shows a loading state while checking.The admin role itself is verified server-side by assertAdmin() from @zooly/util-srv, which resolves the user from the auth cookie and checks for the admin role. All admin API endpoints use assertAdmin().
All admin pages are client components that fetch data on mount and provide interactive controls.
| Page | Route | File |
|---|---|---|
| Bouncer Queue | /admin/offers/queue | apps/zooly-app/app/(pages)/admin/offers/queue/page.tsx |
| All Offers | /admin/offers | apps/zooly-app/app/(pages)/admin/offers/page.tsx |
| User Management | /admin/users | apps/zooly-app/app/(pages)/admin/users/page.tsx |
Displays offers in ADMIN_REVIEW status — every new offer and every counter-offer passes through this queue before the other party can see it.
Data source: GET /api/offers/admin/queue (existing endpoint from Sprint 1)
Features:
totalBrandPriceCent >= 500000 ($5,000+)POST /api/offers/admin/approve with { offerId }POST /api/offers/admin/reject with { offerId, reason }Related API endpoints: see existing offer admin routes in apps/zooly-app/app/api/offers/admin/
Displays a paginated table of all offers on the platform.
Data source: GET /api/offers/list?perspective=admin (existing endpoint)
Features:
Search, list, and block/unblock user accounts.
Data source: GET /api/admin/users and POST /api/admin/users
Features:
?search= query param)POST /api/admin/users with { accountId, action: "block", reason }POST /api/admin/users with { accountId, action: "unblock" }Account blocking is implemented via the brand_limits table (not a column on account):
blockAccount(accountId, reason, adminAccountId) — upserts into brand_limits setting isBlocked = trueunblockAccount(accountId, adminAccountId) — sets isBlocked = falsepackages/db/src/access/brandLimits.tscheckBrandCanCreateOffer)The listAccounts(limit, offset, search?) function in packages/db/src/access/account.ts supports:
search parameter that filters by displayName or slug using case-insensitive ilike{ accounts: Account[], total: number } for paginationTo add a new admin page:
apps/zooly-app/app/(pages)/admin/<section>/page.tsxNAV_ITEMS in apps/zooly-app/app/(pages)/admin/layout.tsxassertAdmin(cookieHeader) from @zooly/util-srv in your API route