Manage users, plans, status, and audit log from /admin/users.
A workspace-driven admin dialog with AD_* plan keys, real ad-platform connection counts, pixel-events quota, Maxxer chat sessions, and a full audit trail.
Overview
The /admin/users page is Admaxxer's internal user-administration surface. It lists every user, lets you open an Edit User dialog, view a per-user activity timeline, and (for super-admins) change role, change plan, or delete the account. Access is gated by the per-user admin role — non-admin users get a 403 from every admin route.
Two admin role tiers exist: the super-admin role can do everything (role, plan, deletion); the support-admin role is read-only on user records (can view the list and timelines, but cannot mutate). The role is set by another super-admin; there is no self-promotion path.
Every mutation made through the admin UI is recorded in the admin audit log — actor, target, action type, previous value, new value, reason, IP, user agent, timestamp. The Admin Logs page (/admin/logs) surfaces recent entries; for older or filtered queries, run SQL directly against the primary database.
Why this page was rewritten (GL#278)
An earlier version of the platform used a legacy schema that stored subscription state on the users.* table — fields like users.subscription_tier, billing_type, monthly_quota, monthly_usage, license_activated_at. When Admaxxer migrated billing to workspaces.* (where the Stripe webhook in server/webhookHandlers.ts now writes), the admin Edit User dialog kept reading and writing the old users.* columns. The result: the dialog showed dead data ("License Status: Active"), offered a dropdown of legacy tiers that no Stripe pipeline ever wrote, and saving any change updated the wrong table — a silent no-op. The rewrite is workspace-driven, plan keys are AD_* (the keys Stripe actually writes), and the activity timeline drops the legacy event types in favor of Admaxxer-real signals (connection_added, pixel_installed, chat_started).
Plan management
Changing a plan in the Edit User dialog calls PATCH /api/admin/users/:userId/plan. The server writes to the user's primary workspace:
workspaces.subscription_plan— the AD_* plan keyworkspaces.subscription_status— one of trialing / active / past_due / canceled / paused / inactiveworkspaces.max_connections— derived fromPLANS[planKey].maxConnectionsworkspaces.max_chat_messages_per_month— derived fromPLANS[planKey].maxChatMessagesPerMonthworkspaces.max_team_members— derived fromPLANS[planKey].maxTeamMembers
Status-driven side effects:
- If
status='active',current_period_endis set toNOW() + 30 days(orNOW() + 365 daysfor AD_*_ANNUAL plans). - If
status='trialing'andusers.trial_ends_atis null, it's bootstrapped toNOW() + 7 days. (To extend an existing trial, see "Direct DB writes" below.) - Every change writes a row to the admin audit log.
Plans available
Five base plans plus annual variants (suffix _ANNUAL). Annual saves ~17% across the board. The internal key AD_AGENCY renders as "Scale" in the UI for legacy DB back-compat — don't rename it.
| Internal plan key | UI display | Monthly | Annual | Tracked events | Notes |
|---|---|---|---|---|---|
AD_STARTER |
Starter | $9 / mo | $90 / yr (~17% off) | 15,000 events / mo | |
AD_GROWTH |
Growth | $29 / mo | $288 / yr | 100,000 events / mo | |
AD_PRO |
Pro | $79 / mo | $792 / yr | 750,000 events / mo | Most popular |
AD_AGENCY |
Scale | $199 / mo | $1,992 / yr | 3,000,000 events / mo | Internal key AD_AGENCY — UI displays 'Scale' (legacy DB compat) |
AD_ENTERPRISE |
Enterprise | $499 / mo | $4,992 / yr | 15,000,000 events / mo |
Source of truth: shared/pricing.ts. Adding a new plan = updating that file plus the webhook tier resolver in server/webhookHandlers.ts and VALID_PLAN_KEYS in lockstep (see GL#26 for the cross-file drift risk).
Status meanings
The workspaces.subscription_status column has six valid values. Each determines billing state, app access, and what the Edit User dialog shows next:
| Status | Meaning | Example |
|---|---|---|
trialing |
Workspace is in a free 7-day trial. App-managed (not Stripe-managed). | Fresh signup. users.trial_ends_at = NOW() + 7d. No card on file required. |
active |
Subscription is current. Billing healthy. Quotas applied. | Stripe webhook customer.subscription.updated set status='active' on plan purchase. |
past_due |
Stripe payment retry in progress. Customer still has access; flag visible in admin. | invoice.payment_failed fired — Stripe Smart Retries running over the next 2-3 weeks. |
canceled |
Subscription canceled in Stripe. Service ends at current_period_end. | Customer canceled via Customer Portal; data preserved 90 days post-period-end. |
paused |
Subscription paused (e.g. seasonal break). No billing, no access until resumed. | Customer pause request fulfilled via Stripe Dashboard — manually advanced from active. |
inactive |
Default 'no active subscription' state. Used pre-trial and post-cancel. | User account exists but never started trial OR post-cancellation grace period elapsed. |
The ExpiredTrialGate component on the React side routes any user whose status is expired / paused / past_due / canceled / inactive / incomplete_expired (or trialing with a past trial_ends_at) to /billing?expired=true. Admin pages bypass this gate so admins can still triage broken accounts.
Workspace Snapshot fields
The Edit User dialog renders a "Workspace Snapshot" card showing the user's primary workspace at a glance. These are the fields:
- Pixel Sites — count of
pixel_websitesrows for the workspace. Each represents a domain with the first-party Admaxxer pixel installed. - Ad Connections — total active
ad_platform_connections, broken out by platform (Meta / Google / TikTok / Klaviyo). "Active" excludes soft-deleted connections. - Pixel Events This Period —
monthly_events_used / PLANS[planKey].eventsPerMonth. Resets each billing cycle. The denominator is the plan'seventsPerMonthfromshared/pricing.ts(e.g. AD_PRO = 750,000). - Maxxer Chat sessions — count of
chat_sessionsrows for the workspace, distinct fromchat_messages(one session can contain many messages). Useful for distinguishing power users from one-time tinkerers.
Activity timeline sources
Click "View activity" on a user row to open a chronological timeline of every notable event for that user. The timeline is built by server/routes/adminUsersActivity.ts, which queries multiple tables and merges them by timestamp. Event types currently surfaced:
| Event type | Source | Description |
|---|---|---|
signup |
users.created_at |
Account created. Time of first signup, regardless of provider (email, Google, Apple). |
login |
sessions |
Successful login session created. Each new session is a row. |
onboarding |
users.onboarding_completed_at |
Onboarding wizard completion timestamp. One-time event. |
billing |
stripe_events / workspaces.subscription_status changes |
Stripe webhook deliveries — subscription created, updated, canceled, or payment failed. |
connection_added |
ad_platform_connections.created_at |
Customer connected a Meta, Google, TikTok, or Klaviyo account. |
pixel_installed |
pixel_websites.installed_at |
First-party pixel verified on a website. Set when pixel fires its first event. |
chat_started |
chat_sessions.created_at |
User opened a Maxxer chat session. |
admin_action |
the admin audit log (actions where this user is the actor) |
An action this user took as admin against another user (e.g. plan change). |
admin_action_on_user |
the admin audit log (actions where this user is the target) |
An action another admin took against this user (e.g. role change, plan change, deletion). |
Note (GL#278): an earlier version of the platform's timeline included several legacy event types. These have no underlying tables on Admaxxer (we are an ad-analytics SaaS) and were always returning empty. They were dropped in the rewrite. If a future Admaxxer feature warrants a new event type (e.g. workspace_invite_accepted), add it to adminUsersActivity.ts and document it in the table above.
Permissions
Two admin role tiers, stored as a per-user role setting:
- Super-admin — full access. Can change another user's role, change another user's plan, delete user accounts, run all admin SQL queries, and view every admin page (
/admin/users,/admin/stats,/admin/logs,/admin/limits,/admin/queues,/admin/claude,/admin/config,/admin/app-review). - Support-admin — read-only on user records. Can view the Users list, view a user's Activity timeline, and read aggregate stats. Cannot edit plan, change role, or delete users.
Non-admin users have no admin role set (the default) and get a 403 from every admin route. Promotion to admin is a manual SQL update — there is no self-service path. Demotion is the same SQL update in reverse, also logged to the admin audit log when done via the admin UI.
Audit log
Every mutation made through the admin UI is recorded in the admin audit log. Each entry captures:
- A unique entry ID
- The admin who took the action (actor)
- The user being modified (target; omitted for non-user-scoped actions like config edits)
- The action type — plan change, role change, user deletion, etc.
- A snapshot of the state before the change
- A snapshot of the state after the change
- A free-text reason provided by the admin (recommended for plan changes / role changes)
- The admin's request IP
- The admin's browser user agent
- A timestamp
The Admin Logs page (/admin/logs) surfaces the most recent ~100 entries, filterable by actor, target, and action type. For older entries or complex queries, query the log directly in the primary database.
Worked examples
Example 1 — Extend a fresh user's trial by 7 days
If the user's users.trial_ends_at is currently null (e.g. they signed up but never started using the product), open Edit User and set status to Trialing. The dialog will set trial_ends_at = NOW() + 7d and workspaces.subscription_status='trialing'. The user will see a fresh 7-day trial banner on next page load.
To extend an EXISTING active trial, the dialog does not currently expose a UI affordance. Run a direct DB update via our primary database console:
UPDATE users
SET trial_ends_at = trial_ends_at + INTERVAL '7 days'
WHERE id = '<user_id>';
(See "Direct DB writes" below for context on when escape-hatch SQL is appropriate.)
Example 2 — Cancel a user immediately
Two-step process. Step 1: in the Edit User dialog, set status to Inactive. This sets workspaces.subscription_status='inactive' and clears the user from the active-subscription pool. The ExpiredTrialGate on the client will route the user to /billing?expired=true on next page load, blocking app access.
Step 2: separately cancel the actual Stripe subscription in the Stripe Dashboard so future invoices don't generate. Skipping step 2 means Stripe will keep trying to renew at current_period_end and emit billing webhooks against a deactivated workspace — confusing for the customer (failed-payment emails for a "canceled" account) and noisy in the logs. The Stripe Dashboard cancel option is under Customers → [customer] → Subscriptions → Cancel subscription.
Direct DB writes (escape hatch)
For edge cases that the Edit User dialog doesn't cover — e.g. extending an existing active trial, backfilling a quota override for a specific customer, fixing a webhook-dropped state — write SQL directly against our primary database. The active database is documented in CLAUDE.md (Database Rule); the URL is not repeated here.
Two channels:
- our primary database Console — log in to our primary database, open the admaxxer project, switch to the SQL Editor tab. Audit trail: our primary database's own query log.
- psql / pg client — connect with the URL from our infrastructure env. Useful for scripted backfills and CSV exports.
Always run a SELECT first before any UPDATE or DELETE. Wrap mutations in a transaction (BEGIN; UPDATE ...; COMMIT;) so a typo can be rolled back. Direct DB writes do not trigger the admin audit trail — for any change worth auditing, prefer the UI path or manually record a corresponding audit-log entry.
Frequently asked
- Why doesn't the Edit User dialog show the legacy fields anymore (License Status, Subscription Tier dropdown)?
- An earlier version of the platform stored subscription state on users.* (subscription_tier, monthly_quota, monthly_usage, license_activated_at, billing_type). When Admaxxer migrated billing to workspaces.* (where Stripe webhooks now write), the admin dialog kept reading the dead users.* columns — so it showed stale data and saving any change was a silent no-op. The dialog is now workspace-driven: plan changes write to workspaces.subscription_plan, status to workspaces.subscription_status, quotas to workspaces.max_connections / max_chat_messages_per_month / max_team_members. The legacy users.* columns still exist for trial bootstrap (subscription_tier='trial' is read elsewhere for trial detection) but are no longer surfaced or edited via admin UI. See GL#278.
- What plan keys can I assign in the Edit User dialog?
- Five plans plus annual variants: AD_STARTER (Starter $9/mo, 15k events), AD_GROWTH (Growth $29/mo, 100k events), AD_PRO (Pro $79/mo, 750k events), AD_AGENCY (Scale $199/mo, 3M events), AD_ENTERPRISE (Enterprise $499/mo, 15M events). Annual variants suffix _ANNUAL (e.g. AD_PRO_ANNUAL). The internal key AD_AGENCY displays as 'Scale' in the UI for legacy DB compatibility — don't rename it. All plan keys are defined in shared/pricing.ts (single source of truth).
- What happens when I change a user's plan in Edit User?
- PATCH /api/admin/users/:userId/plan updates the subscription plan, status, connection limit, chat-message limit, and team-seat limit for the user's primary workspace. If status='active', the current period end is set to NOW + 30d (or NOW + 365d for AD_*_ANNUAL plans). If status='trialing' and the trial end date is null, it's bootstrapped to NOW + 7d. Every change is logged to the admin audit log with the actor, target, previous value, new value, reason, IP, and timestamp.
- How do I extend a user's existing trial by 7 days?
- If the user's subscription_status is already 'trialing' but their trial_ends_at is approaching, the Edit User dialog won't extend it — setting status to 'trialing' only bootstraps trial_ends_at when it's null. To extend an existing trial, run a direct DB update via our primary database: UPDATE users SET trial_ends_at = trial_ends_at + INTERVAL '7 days' WHERE id = '<user_id>'. We may add a UI affordance for this in a future revision.
- How do I cancel a user immediately?
- Two-step process. (1) In Edit User, set status to Inactive — this clears the active subscription state in workspaces.subscription_status. (2) Cancel the actual Stripe subscription separately in the Stripe Dashboard so future invoices don't generate. Skipping step 2 means Stripe will keep trying to renew at period end and emit billing webhooks against a deactivated workspace.
- What's the difference between the super-admin and support-admin roles?
- The super-admin role can do everything: change roles, change plans, delete users, and access all admin pages. The support-admin role is read-only on user records — they can view the Users list and the per-user Activity timeline but cannot edit plan, role, or delete the user. The role is stored as a per-user setting; non-admin users have no role set (the default).
- Where is the audit log stored?
- Every mutation made through the admin UI is recorded in the admin audit log with: the admin who took the action, the user being modified, the action type (plan change, role change, deletion), the previous value, the new value, a free-text reason from the admin, the originating IP, the user agent, and a timestamp. The Admin Logs page (/admin/logs) shows recent entries; for older or filtered queries, query the log directly in the primary database.
- Why do some legacy event types not appear in the activity timeline anymore?
- An earlier version of the platform's timeline included several event types that have no underlying tables on Admaxxer and were always returning empty. Admaxxer is a DTC ad-analytics platform; those legacy types were dropped. The current Admaxxer-relevant types are: signup, login, onboarding, billing, connection_added, pixel_installed, chat_started, admin_action, admin_action_on_user.