Documentation · Admin user management.

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.

Back to documentation hub Billing & Plans doc

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:

Status-driven side effects:

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.

Admaxxer plan keys — internal key, UI label, pricing, tracked-event quota (canonical: shared/pricing.ts)
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:

workspaces.subscription_status — meaning and worked example per status
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:

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:

Activity timeline event types — source table/column and meaning (server/routes/adminUsersActivity.ts)
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:

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:

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:

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.