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.
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:
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
admin_actions_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) | 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:
| 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 = 1,000,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 |
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:
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 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:
id— UUID primary keyactor_user_id— the admin who took the actiontarget_user_id— the user being modified (nullable for non-user-scoped actions like config edits)action_type— one ofplan_change,role_change,user_deletion, etc.previous_value— JSON snapshot of the state before the changenew_value— JSON snapshot of the state after the changereason— free-text explanation provided by the admin (recommended for plan changes / role changes)ip_address— admin's request IPuser_agent— admin's browser UAcreated_at— 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 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:
- Neon Console — log in to Neon, open the admaxxer project, switch to the SQL Editor tab. Audit trail: Neon's own query log.
- psql / pg client — connect with the URL from Coolify 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_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.