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. Forked from WarmySender, rewritten for Admaxxer.

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 users.admin_role column — non-admin users get a 403 from every admin route.

Two admin role tiers exist: super_admin can do everything (role, plan, deletion); support_admin 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 admin_actions_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 Neon.

Why this page was rewritten (GL#278)

Admaxxer was forked from WarmySender, a cold-email SaaS. WarmySender 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 ("Emails Sent: 0", "License Status: Active"), offered a dropdown of WarmySender tiers (lite/solo/growth/scale/agency/dominate/rampage) 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 WarmySender-era email 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) 25,000 events / mo
AD_GROWTH Growth $29 / mo $288 / yr 250,000 events / mo
AD_PRO Pro $79 / mo $792 / yr 1,000,000 events / mo Most popular
AD_AGENCY Scale $199 / mo $1,992 / yr 5,000,000 events / mo Internal key AD_AGENCY — UI displays 'Scale' (legacy DB compat)
AD_ENTERPRISE Enterprise $499 / mo $4,992 / yr 25,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 admin_actions_log (actor_user_id = this user) An action this user took as admin against another user (e.g. plan change).
admin_action_on_user admin_actions_log (target_user_id = this user) An action another admin took against this user (e.g. role change, plan change, deletion).

Note (GL#278): the original WarmySender-era timeline included mailbox_connected, campaign_created, sequence_created, prospects_imported, and email_activity event types. These have no underlying tables on Admaxxer (we are an ad-analytics SaaS, not a cold-email 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, defined as a string column on users.admin_role:

Non-admin users have admin_role = NULL (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 admin_actions_log when done via the admin UI.

Audit log

Every mutation made through the admin UI is recorded in admin_actions_log. The schema:

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 table directly via the Neon console or psql.

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 the Neon 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 Neon. 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_actions_log audit trail — for any change worth auditing, prefer the UI path or manually INSERT a corresponding admin_actions_log row.

Frequently asked

Why doesn't the Edit User dialog show the WarmySender-era fields anymore (Emails Sent, License Status, Subscription Tier dropdown)?
Admaxxer was forked from WarmySender (a cold-email SaaS). WarmySender 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, 25k events), AD_GROWTH (Growth $29/mo, 250k events), AD_PRO (Pro $79/mo, 1M events), AD_AGENCY (Scale $199/mo, 5M events), AD_ENTERPRISE (Enterprise $499/mo, 25M 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 writes to workspaces.subscription_plan, workspaces.subscription_status, workspaces.max_connections, workspaces.max_chat_messages_per_month, and workspaces.max_team_members for the user's primary workspace. If status='active', current_period_end is set to NOW + 30d (or NOW + 365d for AD_*_ANNUAL plans). If status='trialing' and users.trial_ends_at is null, it's bootstrapped to NOW + 7d. Every change is logged to admin_actions_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 Neon Postgres: 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 super_admin and support_admin?
super_admin can do everything: change roles, change plans, delete users, and access all admin pages. support_admin 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. Roles are stored on users.admin_role; non-admin users have admin_role=null (the default).
Where is the audit log stored?
Every mutation made through the admin UI is recorded in admin_actions_log with: actor_user_id (the admin who took the action), target_user_id (the user being modified), action_type (plan_change, role_change, deletion), previous_value, new_value, reason (free-text from the admin), ip_address, user_agent, and created_at. The Admin Logs page (/admin/logs) shows recent entries; for older or filtered queries, query the table directly via Neon.
Why do email-only events not appear in the activity timeline anymore?
The original timeline (forked from WarmySender) included mailbox_connected, campaign_created, sequence_created, prospects_imported, and email_activity event types — all leftover from the cold-email SaaS. Admaxxer is a DTC ad-analytics platform; those event types have no underlying tables and were always returning empty. The current Admaxxer-relevant types are: signup, login, onboarding, billing, connection_added, pixel_installed, chat_started, admin_action, admin_action_on_user.