From ce49497a6a4b1fa9bc7da72a595df9fa40b87c45 Mon Sep 17 00:00:00 2001 From: pguerrerox Date: Thu, 28 May 2026 12:46:06 +0000 Subject: [PATCH] feat: complete admin phase C and add safe mutation pilot --- README.md | 30 ++ TODO-pricing.md | 66 ++- db/migrations/0009_admin_ops_indexes.sql | 8 + server/src/app.ts | 2 + server/src/auth/admin.ts | 152 +++++- server/src/billing/repository.ts | 140 +++++ server/src/config/env.ts | 13 + server/src/payments/repository.ts | 64 +++ server/src/payments/service.ts | 31 +- server/src/routes/admin-access.ts | 7 +- server/src/routes/admin-bootstrap.ts | 10 +- server/src/routes/admin-ops.ts | 259 ++++++++++ server/src/routes/analytics.ts | 4 +- server/src/routes/billing.ts | 6 +- shared/types.ts | 105 ++++ src/components/AdminPage.tsx | 621 ++++++++++++++++++++++- src/lib/admin.ts | 71 +++ 17 files changed, 1568 insertions(+), 21 deletions(-) create mode 100644 db/migrations/0009_admin_ops_indexes.sql create mode 100644 server/src/routes/admin-ops.ts diff --git a/README.md b/README.md index bbb9b8f..564619a 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,36 @@ Notes: - Billing return notices now appear on the account page for completed and canceled checkout flows. - Internal billing support visibility is available through `/api/admin/billing/workspaces` for allowlisted admin emails. +## Admin Operations Runbook + +Bootstrap/security hardening checklist after first-run admin setup: + +- Set `ALLOW_ADMIN_BOOTSTRAP=false` and redeploy API. +- Rotate `ADMIN_BOOTSTRAP_TOKEN` and store it in your secrets manager. +- Ensure at least two active app-admin identities are configured. +- Prefer `ADMIN_EMAILS`; stop relying on deprecated `BILLING_ADMIN_EMAILS` fallback. + +Support diagnostics starting thresholds: + +- Failed webhooks: investigate when there are 5+ failures in 15 minutes or 20+ failures in 24 hours. +- Stale sync accounts: investigate when 10+ workspaces are stale for more than 24 hours. +- Repeated payment failures: investigate any workspace with 3+ `invoice_payment_failed` events in 7 days. +- Pending plan effective in past: investigate when count remains above 0 for more than 2 hours. + +First-response sequence for repeated failures: + +1. Open Admin Console diagnostics and capture affected workspace IDs. +2. Open each workspace detail and review recent timeline and webhook event history. +3. Verify Stripe webhook delivery status and replay failed events where safe. +4. Confirm billing sync recovers and anomaly counts return toward baseline. +5. Escalate with captured event IDs and workspace IDs if issues persist. + +Safe mutation pilot: + +- Admin Console now includes a constrained billing resync mutation. +- Required inputs: `workspaceId`, operational reason, and typed confirmation (`RESYNC`). +- Optional input: `ticketRef` for support/incident traceability. + ## Docker Deployment 1. Copy `.env.example` to `.env` and set at least: diff --git a/TODO-pricing.md b/TODO-pricing.md index 2a9eea9..00c6277 100644 --- a/TODO-pricing.md +++ b/TODO-pricing.md @@ -97,11 +97,29 @@ - [x] Phase B (Admin Access Management): Add admin UI for managing app-admin identities with status visibility (active/disabled). - [x] Phase B (Admin Access Management): Prevent accidental lockout with guardrails (e.g., disallow disabling the last active admin). - [x] Phase B (Admin Access Management): Add explicit audit entries for admin identity mutations. -- [ ] Phase C (Audit & Support Operations): Add admin audit log page/table with filters (actor, action, workspace, date window). -- [ ] Phase C (Audit & Support Operations): Expose bootstrap/security posture checks in admin UI (bootstrap enabled state, fallback allowlist usage warnings). -- [ ] Phase C (Audit & Support Operations): Add support-oriented diagnostics widgets (recent webhook issues, billing sync errors, timeline anomalies). +- [x] Phase C execution split (implementation sequencing) + - [x] Sub-step 1 (Backend foundations): ship admin audit list API + security posture API + diagnostics aggregate API, plus shared types/client contracts. + - [x] Sub-step 2 (Admin Console UI): add audit explorer table/filters, security posture card, and diagnostics widgets with drill-down links. + - [x] Sub-step 3 (Hardening & operations): normalize audit action taxonomy, tune indexes/query performance, and finalize runbook/alerts for repeated failures. +- [x] Phase C (Audit & Support Operations): Add admin audit log page/table with filters (actor, action, workspace, date window). + - [x] Add backend admin audit-list endpoint with filters for actor email, action, workspace ID, date range, and pagination. + - [x] Review audit-log query performance and indexes to ensure efficient filtering and default sorting by `occurred_at DESC`. + - [x] Implement admin audit table UI with filter controls plus clear loading, empty, and error states. + - [x] Normalize admin audit action taxonomy so admin-route events use consistent action names. +- [x] Phase C (Audit & Support Operations): Expose bootstrap/security posture checks in admin UI (bootstrap enabled state, fallback allowlist usage warnings). + - [x] Add backend admin security-posture endpoint returning `bootstrapRequired`, `bootstrapEnabled`, and fallback allowlist usage status. + - [x] Add warning semantics when deprecated `BILLING_ADMIN_EMAILS` fallback is active. + - [x] Add admin UI security-posture card with explicit remediation guidance for risky states. + - [x] Document post-bootstrap hardening checklist: disable bootstrap, rotate bootstrap token, and verify at least two active admins. +- [x] Phase C (Audit & Support Operations): Add support-oriented diagnostics widgets (recent webhook issues, billing sync errors, timeline anomalies). + - [x] Add backend diagnostics endpoint aggregating recent failed webhook events, stale billing-sync accounts, and recent timeline-anomaly counts. + - [x] Define timeline-anomaly heuristics (for example: repeated `payment_failed`, pending plan effective date in the past, and stale sync threshold breaches). + - [x] Add admin diagnostics widgets with counts and drill-down links to existing workspace detail views. + - [x] Define alerting and runbook follow-up tasks for repeated failures surfaced by diagnostics. - [ ] Phase D (Safe Mutations, later): Keep initial admin console read-only for billing data; defer write/mutation actions until policies and runbooks are defined. + - [x] Pilot: add constrained `billing resync` admin mutation with non-destructive intent and explicit operator guidance. - [ ] Phase D (Safe Mutations, later): For future write actions, require explicit confirmations, actor attribution, and rollback guidance. + - [x] Pilot guardrails in place: required reason, typed confirmation (`RESYNC`), optional `ticketRef`, and admin audit attribution. ## 13) [DEFER] Operational Enforcement Follow-Up - [ ] Add queue prioritization by plan tier. @@ -134,9 +152,49 @@ - [ ] Phase 7: harden post-payments lifecycle handling, wire real billing CTAs, and add pragmatic admin billing visibility before broader commercialization work. - [ ] Phase 7a: ship dedicated read-only admin console and migrate existing admin billing tools out of the account page. - [ ] Phase 7b: ship app-admin identity management APIs/UI with last-admin lockout protection and audit logging. -- [ ] Phase 7c: ship admin audit explorer and support diagnostics views. +- [x] Phase 7c: ship admin audit explorer and support diagnostics views (Phase C from section 12). - [ ] Phase 7d: evaluate controlled admin write-actions only after policy/runbook readiness. +- [ ] Phase 7e: MinIO/object-storage foundation + dataset registry schema. +- [ ] Phase 7f: postal ingestion worker pipeline + admin APIs. +- [ ] Phase 7g: admin console postal dataset operations and activation workflow. - [ ] Phase 8: expand analytics, ops, and revenue instrumentation around the live billing and upgrade flows. - [ ] Phase 9: launch collaboration, API, enrichment, and enterprise features as architecture matures. - [ ] Phase 10: complete deferred operational enforcement work such as queue prioritization, throttling, and backend export enforcement when runtime scale justifies it. - [ ] Phase 11: decide and implement founder/LTD strategy only after the app/site, billing lifecycle, admin/support visibility, analytics, and broader product maturity work are in place. + +## 16) Multi-Country Postal Dataset Onboarding (MinIO-backed) +- Architecture decision + - [ ] Standardize on self-hosted MinIO (S3-compatible) for postal dataset storage and processing. + - [ ] Retire host-mounted files and manual CLI-only import as primary onboarding paths. +- Infrastructure/bootstrap + - [ ] Add MinIO service to Docker deployment with persistent volume and health checks. + - [ ] Define credential bootstrap/rotation expectations and automated bucket creation for postal datasets. + - [ ] Document backup/restore expectations (RPO/RTO target, snapshot cadence, and restore verification). +- Config/env + - [ ] Add and document `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE`, and `S3_BUCKET_POSTAL_DATASETS`. + - [ ] Wire S3-compatible config into both API and worker runtime boot paths. +- Data model + - [ ] Add `postal_datasets` schema for object metadata, versioning, and activation status lifecycle. + - [ ] Add `postal_dataset_runs` schema for run tracking, timing, actor/source metadata, and run types. + - [ ] Add `postal_country_support` schema/state fields for per-country readiness, coverage, and active dataset linkage. + - [ ] Define lifecycle states and transitions (draft, uploaded, validated, processing, ready, active, failed, archived) and enforce transition guards. +- API/admin flows + - [ ] Add admin-authenticated dataset register + upload URL flow for object ingest. + - [ ] Add validate/process/activate endpoints and dataset run history/read APIs. + - [ ] Require admin authorization and audit logging for all mutating dataset actions. +- Worker pipeline + - [ ] Implement queued jobs: `postal.validate`, `postal.import`, `postal.neighbors`, `postal.check`, `postal.activate`. + - [ ] Enforce idempotency keys and per-country mutex/locking to prevent conflicting runs. + - [ ] Track progress checkpoints and standard retry/backoff policy with terminal failure states. +- Country adapters + - [ ] Implement pluggable country-specific validation/normalization profiles. + - [ ] Enforce country geometry/topology constraints (bounds, shapes, adjacency expectations) during validation. +- Safety/operability + - [ ] Keep activation behind an explicit admin gate after successful checks. + - [ ] Preserve previous active dataset for rollback and support fast re-activation. + - [ ] Add alerts and runbook steps for failed, stalled, or long-running jobs. +- UX/admin console + - [ ] Add postal dataset list with status, country, version, and activation markers. + - [ ] Add run logs/error visibility and filtered run history in admin console. + - [ ] Add activation controls with confirmation guardrails and rollback visibility. + - [ ] Add per-country readiness visibility so operators can see launch coverage at a glance. diff --git a/db/migrations/0009_admin_ops_indexes.sql b/db/migrations/0009_admin_ops_indexes.sql new file mode 100644 index 0000000..7e1fc14 --- /dev/null +++ b/db/migrations/0009_admin_ops_indexes.sql @@ -0,0 +1,8 @@ +create index if not exists admin_access_audit_action_occurred_at_idx + on public.admin_access_audit (action, occurred_at desc); + +create index if not exists admin_access_audit_workspace_occurred_at_idx + on public.admin_access_audit (target_workspace_id, occurred_at desc); + +create index if not exists admin_access_audit_actor_email_lower_idx + on public.admin_access_audit (lower(actor_email)); diff --git a/server/src/app.ts b/server/src/app.ts index 2d95507..10bdd12 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -11,6 +11,7 @@ import { searchJobRoutes } from './routes/search-jobs.js'; import { analyticsRoutes } from './routes/analytics.js'; import { adminBootstrapRoutes } from './routes/admin-bootstrap.js'; import { adminAccessRoutes } from './routes/admin-access.js'; +import { adminOpsRoutes } from './routes/admin-ops.js'; function parseAllowedOrigins(rawOrigins: string) { return rawOrigins @@ -60,6 +61,7 @@ export async function buildApp() { await app.register(analyticsRoutes, { prefix: '/api' }); await app.register(adminBootstrapRoutes, { prefix: '/api' }); await app.register(adminAccessRoutes, { prefix: '/api' }); + await app.register(adminOpsRoutes, { prefix: '/api' }); return app; } diff --git a/server/src/auth/admin.ts b/server/src/auth/admin.ts index 5825a45..96bd50c 100644 --- a/server/src/auth/admin.ts +++ b/server/src/auth/admin.ts @@ -6,6 +6,24 @@ import { getDbPool } from '../db/pool.js'; type DbClient = Pool | PoolClient; +export const ADMIN_AUDIT_ACTIONS = { + ADMIN_ACCESS_LIST: 'admin_access_list', + ADMIN_ACCESS_UPSERT: 'admin_access_upsert', + ADMIN_ACCESS_STATUS_CHANGED: 'admin_access_status_changed', + ANALYTICS_SUMMARY: 'analytics_summary', + BILLING_WORKSPACES_LIST: 'billing_workspaces_list', + BILLING_WORKSPACE_DETAIL: 'billing_workspace_detail', + BOOTSTRAP_ADMIN_CLAIMED: 'bootstrap_admin_claimed', + ADMIN_OPS_AUDIT_LIST: 'admin_ops_audit_list', + ADMIN_OPS_SECURITY_POSTURE: 'admin_ops_security_posture', + ADMIN_OPS_DIAGNOSTICS: 'admin_ops_diagnostics', + ADMIN_MUTATION_BILLING_RESYNC_REQUESTED: 'admin_mutation_billing_resync_requested', +} as const; + +export type AdminAuditAction = (typeof ADMIN_AUDIT_ACTIONS)[keyof typeof ADMIN_AUDIT_ACTIONS]; + +export const ADMIN_AUDIT_ACTION_VALUES = Object.values(ADMIN_AUDIT_ACTIONS) as [AdminAuditAction, ...AdminAuditAction[]]; + type ApplicationAdminRow = { id: string; email: string; @@ -17,6 +35,40 @@ type ApplicationAdminRow = { updated_at: string; }; +type AdminAccessAuditRow = { + id: string; + actor_user_id: string | null; + actor_email: string | null; + route: string; + action: string; + target_workspace_id: string | null; + target_workspace_name: string | null; + metadata_json: Record; + occurred_at: string; +}; + +export type AdminAccessAuditSummary = { + id: string; + actorUserId: string | null; + actorEmail: string | null; + route: string; + action: AdminAuditAction; + targetWorkspaceId: string | null; + targetWorkspaceName: string | null; + metadataJson: Record; + occurredAt: string; +}; + +export type AdminAccessAuditListFilters = { + actorEmail?: string; + action?: AdminAuditAction; + workspaceId?: string; + from?: string; + to?: string; + limit: number; + offset: number; +}; + function toApplicationAdminSummary(row: ApplicationAdminRow): ApplicationAdminSummary { return { id: row.id, @@ -33,7 +85,7 @@ type AdminAccessAuditPayload = { actorUserId?: string | null; actorEmail?: string | null; route: string; - action: string; + action: AdminAuditAction; targetWorkspaceId?: string | null; metadataJson?: Record; occurredAt?: string; @@ -222,3 +274,101 @@ export async function recordAdminAccessAudit(db: DbClient, payload: AdminAccessA ], ); } + +function buildAdminAccessAuditWhere(filters: Omit) { + const clauses: string[] = []; + const params: Array = []; + + if (filters.actorEmail?.trim()) { + params.push(`%${filters.actorEmail.trim().toLowerCase()}%`); + clauses.push(`lower(audit.actor_email) like $${params.length}`); + } + + if (filters.action?.trim()) { + params.push(filters.action.trim()); + clauses.push(`audit.action = $${params.length}`); + } + + if (filters.workspaceId?.trim()) { + params.push(filters.workspaceId.trim()); + clauses.push(`audit.target_workspace_id = $${params.length}::uuid`); + } + + if (filters.from) { + params.push(filters.from); + clauses.push(`audit.occurred_at >= $${params.length}::timestamptz`); + } + + if (filters.to) { + params.push(filters.to); + clauses.push(`audit.occurred_at <= $${params.length}::timestamptz`); + } + + return { + whereSql: clauses.length > 0 ? `where ${clauses.join(' and ')}` : '', + params, + }; +} + +function mapAdminAccessAuditRow(row: AdminAccessAuditRow): AdminAccessAuditSummary { + return { + id: row.id, + actorUserId: row.actor_user_id, + actorEmail: row.actor_email, + route: row.route, + action: row.action as AdminAuditAction, + targetWorkspaceId: row.target_workspace_id, + targetWorkspaceName: row.target_workspace_name, + metadataJson: row.metadata_json, + occurredAt: row.occurred_at, + }; +} + +export async function countAdminAccessAudit( + db: DbClient, + filters: Omit, +): Promise { + const built = buildAdminAccessAuditWhere(filters); + const result = await db.query<{ count: string }>( + ` + select count(*)::text as count + from public.admin_access_audit audit + ${built.whereSql} + `, + built.params, + ); + + return Number(result.rows[0]?.count ?? '0'); +} + +export async function listAdminAccessAudit(db: DbClient, filters: AdminAccessAuditListFilters): Promise { + const built = buildAdminAccessAuditWhere(filters); + const params = [...built.params, filters.limit, filters.offset]; + const limitParam = `$${built.params.length + 1}`; + const offsetParam = `$${built.params.length + 2}`; + + const result = await db.query( + ` + select + audit.id, + audit.actor_user_id, + audit.actor_email, + audit.route, + audit.action, + audit.target_workspace_id, + workspace.name as target_workspace_name, + audit.metadata_json, + audit.occurred_at + from public.admin_access_audit audit + left join public.workspaces workspace + on workspace.id = audit.target_workspace_id + ${built.whereSql} + order by audit.occurred_at desc + limit ${limitParam} + offset ${offsetParam} + `, + params, + ); + + return result.rows.map(mapAdminAccessAuditRow); +} diff --git a/server/src/billing/repository.ts b/server/src/billing/repository.ts index fab8b3a..4c0f976 100644 --- a/server/src/billing/repository.ts +++ b/server/src/billing/repository.ts @@ -133,6 +133,17 @@ type BillingAdminWorkspaceSummaryRow = { external_subscription_ref: string | null; }; +type BillingStaleSyncWorkspaceRow = { + workspace_id: string; + workspace_name: string; + billing_sync_status: BillingSyncStatus; + last_stripe_sync_at: string | null; + status: AccountBillingStatus; + plan_code: string | null; + pending_plan_code: string | null; + pending_plan_effective_at: string | null; +}; + type UsagePeriodRow = { id: string; workspace_id: string; @@ -174,6 +185,23 @@ type AddonPurchaseRow = { updated_at: string; }; +export type BillingStaleSyncWorkspaceSummary = { + workspaceId: string; + workspaceName: string; + billingSyncStatus: BillingSyncStatus; + lastStripeSyncAt: string | null; + status: AccountBillingStatus; + planCode: PlanCode | null; + pendingPlanCode: PlanCode | null; + pendingPlanEffectiveAt: string | null; +}; + +export type BillingTimelineAnomalySummary = { + repeatedPaymentFailedCount: number; + pendingPlanPastEffectiveCount: number; + staleSyncThresholdCount: number; +}; + export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: string): Promise { const result = await db.query( ` @@ -509,6 +537,118 @@ export async function listRecentBillingTimelineEventsForWorkspace(db: DbClient, return result.rows.map(mapBillingTimelineEventRow); } +export async function listStaleBillingSyncWorkspaces( + db: DbClient, + input: { staleThresholdHours?: number; limit?: number } = {}, +): Promise { + const staleThresholdHours = input.staleThresholdHours ?? 24; + const limit = input.limit ?? 10; + + const result = await db.query( + ` + select + w.id as workspace_id, + w.name as workspace_name, + billing.billing_sync_status, + billing.last_stripe_sync_at, + billing.status, + billing.plan_code, + billing.pending_plan_code, + billing.pending_plan_effective_at + from public.workspace_billing_accounts billing + join public.workspaces w on w.id = billing.workspace_id + where + billing.billing_sync_status in ('stale', 'error') + or billing.last_stripe_sync_at is null + or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int) + order by + case when billing.billing_sync_status = 'error' then 0 else 1 end asc, + billing.last_stripe_sync_at asc nulls first + limit $2 + `, + [staleThresholdHours, limit], + ); + + return result.rows.map((row) => ({ + workspaceId: row.workspace_id, + workspaceName: row.workspace_name, + billingSyncStatus: row.billing_sync_status, + lastStripeSyncAt: row.last_stripe_sync_at, + status: row.status, + planCode: row.plan_code as PlanCode | null, + pendingPlanCode: row.pending_plan_code as PlanCode | null, + pendingPlanEffectiveAt: row.pending_plan_effective_at, + })); +} + +export async function countStaleBillingSyncWorkspaces(db: DbClient, staleThresholdHours = 24): Promise { + const result = await db.query<{ count: string }>( + ` + select count(*)::text as count + from public.workspace_billing_accounts billing + where + billing.billing_sync_status in ('stale', 'error') + or billing.last_stripe_sync_at is null + or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int) + `, + [staleThresholdHours], + ); + + return Number(result.rows[0]?.count ?? '0'); +} + +export async function getBillingTimelineAnomalySummary( + db: DbClient, + input: { windowDays?: number; paymentFailedThreshold?: number; staleThresholdHours?: number } = {}, +): Promise { + const windowDays = input.windowDays ?? 7; + const paymentFailedThreshold = input.paymentFailedThreshold ?? 2; + const staleThresholdHours = input.staleThresholdHours ?? 24; + + const repeatedPaymentFailedResult = await db.query<{ count: string }>( + ` + select count(*)::text as count + from ( + select event.workspace_id + from public.workspace_billing_timeline_events event + where event.event_type = 'invoice_payment_failed' + and event.occurred_at >= now() - make_interval(days => $1::int) + group by event.workspace_id + having count(*) >= $2 + ) repeated_failure_workspaces + `, + [windowDays, paymentFailedThreshold], + ); + + const pendingPlanPastEffectiveResult = await db.query<{ count: string }>( + ` + select count(*)::text as count + from public.workspace_billing_accounts billing + where billing.pending_plan_code is not null + and billing.pending_plan_effective_at is not null + and billing.pending_plan_effective_at < now() + `, + ); + + const staleSyncThresholdResult = await db.query<{ count: string }>( + ` + select count(*)::text as count + from public.workspace_billing_accounts billing + where + billing.billing_sync_status in ('stale', 'error') + or billing.last_stripe_sync_at is null + or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int) + `, + [staleThresholdHours], + ); + + return { + repeatedPaymentFailedCount: Number(repeatedPaymentFailedResult.rows[0]?.count ?? '0'), + pendingPlanPastEffectiveCount: Number(pendingPlanPastEffectiveResult.rows[0]?.count ?? '0'), + staleSyncThresholdCount: Number(staleSyncThresholdResult.rows[0]?.count ?? '0'), + }; +} + export async function listBillingAdminWorkspaceSummaries(db: DbClient, search: string | null, limit = 50) { const result = await db.query( ` diff --git a/server/src/config/env.ts b/server/src/config/env.ts index 693e1fa..f9369b0 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -68,11 +68,24 @@ function parseAdminEmailAllowlist(allowlist: string | undefined): string[] { .filter(Boolean); } +function hasEntries(raw: string | undefined) { + return parseAdminEmailAllowlist(raw).length > 0; +} + export function getAdminEmailAllowlist() { const env = getEnv(); return parseAdminEmailAllowlist(env.ADMIN_EMAILS || env.BILLING_ADMIN_EMAILS); } +export function hasAdminEmailAllowlistConfigured() { + return hasEntries(getEnv().ADMIN_EMAILS); +} + +export function isUsingDeprecatedBillingAdminFallback() { + const env = getEnv(); + return !hasEntries(env.ADMIN_EMAILS) && hasEntries(env.BILLING_ADMIN_EMAILS); +} + export function isBillingAdminEmail(email: string) { const allowlist = getAdminEmailAllowlist(); diff --git a/server/src/payments/repository.ts b/server/src/payments/repository.ts index b601c05..3a674f3 100644 --- a/server/src/payments/repository.ts +++ b/server/src/payments/repository.ts @@ -38,6 +38,14 @@ type BillingWebhookEventRow = { updated_at: string; }; +type BillingFailedWebhookEventRow = BillingWebhookEventRow & { + workspace_name: string | null; +}; + +export type BillingFailedWebhookEventSummary = BillingWebhookEventRecord & { + workspaceName: string | null; +}; + export async function recordIncomingWebhookEvent( db: DbClient, input: { @@ -146,6 +154,62 @@ export async function listRecentWebhookEventsForWorkspace(db: DbClient, workspac return result.rows.map(mapBillingWebhookEventRow); } +export async function listRecentFailedWebhookEvents( + db: DbClient, + input: { sinceDays?: number; limit?: number } = {}, +): Promise { + const sinceDays = input.sinceDays ?? 7; + const limit = input.limit ?? 10; + + const result = await db.query( + ` + select + event.id, + event.provider, + event.external_event_id, + event.event_type, + event.status, + event.workspace_id, + event.external_customer_ref, + event.external_subscription_ref, + event.payload_json, + event.error_message, + event.received_at, + event.processed_at, + event.created_at, + event.updated_at, + workspace.name as workspace_name + from public.billing_webhook_events event + left join public.workspaces workspace + on workspace.id = event.workspace_id + where event.status = 'failed' + and event.received_at >= now() - make_interval(days => $1::int) + order by event.received_at desc + limit $2 + `, + [sinceDays, limit], + ); + + return result.rows.map((row) => ({ + ...mapBillingWebhookEventRow(row), + workspaceName: row.workspace_name, + })); +} + +export async function countRecentFailedWebhookEvents(db: DbClient, sinceDays = 7): Promise { + const result = await db.query<{ count: string }>( + ` + select count(*)::text as count + from public.billing_webhook_events event + where event.status = 'failed' + and event.received_at >= now() - make_interval(days => $1::int) + `, + [sinceDays], + ); + + return Number(result.rows[0]?.count ?? '0'); +} + function mapBillingWebhookEventRow(row: BillingWebhookEventRow): BillingWebhookEventRecord { return { id: row.id, diff --git a/server/src/payments/service.ts b/server/src/payments/service.ts index 85ba000..5ee5fb0 100644 --- a/server/src/payments/service.ts +++ b/server/src/payments/service.ts @@ -5,7 +5,14 @@ import { getAddonByCode } from '../../../shared/billing/addons.js'; import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js'; import { getPlanByCode } from '../../../shared/billing/plans.js'; import { ensureWorkspaceForUser } from '../account/repository.js'; -import { ensureBillingAccountForWorkspace, listAddonBalancesForWorkspace, recordAddonPurchase, updateBillingAccountState, upsertAddonBalance } from '../billing/repository.js'; +import { + ensureBillingAccountForWorkspace, + getBillingAccountForWorkspace, + listAddonBalancesForWorkspace, + recordAddonPurchase, + updateBillingAccountState, + upsertAddonBalance, +} from '../billing/repository.js'; import { getEnv } from '../config/env.js'; import { assertAddonSupportsStripeCheckout, @@ -336,6 +343,28 @@ export async function retrieveStripeSubscription(subscriptionId: string) { return stripe.subscriptions.retrieve(subscriptionId); } +export async function requestWorkspaceBillingResyncByAdmin(db: DbClient, input: { workspaceId: string }) { + ensureStripeReady('resync'); + + const billingAccount = await getBillingAccountForWorkspace(db, input.workspaceId); + + if (!billingAccount) { + throw new Error('Billing workspace not found.'); + } + + if (!billingAccount.externalSubscriptionRef) { + throw new Error('Workspace does not have a Stripe subscription reference.'); + } + + const subscription = await retrieveStripeSubscription(billingAccount.externalSubscriptionRef); + await syncWorkspaceBillingFromStripeSubscription(db, subscription, input.workspaceId); + + return { + workspaceId: input.workspaceId, + externalSubscriptionRef: billingAccount.externalSubscriptionRef, + }; +} + export function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): 'active' | 'inactive' | 'past_due' | 'canceled' { switch (status) { case 'active': diff --git a/server/src/routes/admin-access.ts b/server/src/routes/admin-access.ts index 74c3acb..ed0ca78 100644 --- a/server/src/routes/admin-access.ts +++ b/server/src/routes/admin-access.ts @@ -7,6 +7,7 @@ import type { AdminApplicationAdminUpsertRequest, } from '../../../shared/types.js'; import { + ADMIN_AUDIT_ACTIONS, getActiveApplicationAdminCountExcludingId, listApplicationAdmins, recordAdminAccessAudit, @@ -37,7 +38,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => { actorUserId: request.authUser?.id, actorEmail: request.authUser?.email, route: request.routeOptions.url ?? request.url, - action: 'admin_access_list', + action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_LIST, }); const admins = await listApplicationAdmins(db); const response: AdminApplicationAdminsListResponse = { admins }; @@ -60,7 +61,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => { actorUserId: request.authUser?.id, actorEmail: request.authUser?.email, route: request.routeOptions.url ?? request.url, - action: 'admin_access_upsert', + action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_UPSERT, metadataJson: { targetEmail: payload.email.trim(), }, @@ -103,7 +104,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => { actorUserId: request.authUser?.id, actorEmail: request.authUser?.email, route: request.routeOptions.url ?? request.url, - action: 'admin_access_status_changed', + action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_STATUS_CHANGED, metadataJson: { targetAdminId: adminId, nextStatus: payload.status, diff --git a/server/src/routes/admin-bootstrap.ts b/server/src/routes/admin-bootstrap.ts index 25aa441..6a0b21c 100644 --- a/server/src/routes/admin-bootstrap.ts +++ b/server/src/routes/admin-bootstrap.ts @@ -2,7 +2,13 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; import { ZodError, z } from 'zod'; import type { AdminBootstrapClaimResponse, AdminBootstrapStatusResponse } from '../../../shared/types.js'; import { createDefaultWorkspaceForUser } from '../account/repository.js'; -import { createApplicationAdmin, isApplicationAdmin, isBootstrapRequired, recordAdminAccessAudit } from '../auth/admin.js'; +import { + ADMIN_AUDIT_ACTIONS, + createApplicationAdmin, + isApplicationAdmin, + isBootstrapRequired, + recordAdminAccessAudit, +} from '../auth/admin.js'; import { hashPassword } from '../auth/passwords.js'; import { createSession, setSessionCookie } from '../auth/sessions.js'; import { createUser, getUserByEmail, toAppUser } from '../auth/users.js'; @@ -91,7 +97,7 @@ export const adminBootstrapRoutes: FastifyPluginAsync = async (app) => { actorUserId: user.id, actorEmail: user.email, route: '/api/admin/bootstrap/claim', - action: 'bootstrap_admin_claimed', + action: ADMIN_AUDIT_ACTIONS.BOOTSTRAP_ADMIN_CLAIMED, metadataJson: { bootstrapRequiredAtClaim: true, }, diff --git a/server/src/routes/admin-ops.ts b/server/src/routes/admin-ops.ts new file mode 100644 index 0000000..db6687f --- /dev/null +++ b/server/src/routes/admin-ops.ts @@ -0,0 +1,259 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { ZodError, z } from 'zod'; +import type { + AdminAuditLogListResponse, + AdminBillingResyncRequest, + AdminBillingResyncResponse, + AdminSecurityPostureResponse, + AdminSupportDiagnosticsResponse, +} from '../../../shared/types.js'; +import { + ADMIN_AUDIT_ACTIONS, + ADMIN_AUDIT_ACTION_VALUES, + countAdminAccessAudit, + getActiveApplicationAdminCount, + isBootstrapRequired, + listAdminAccessAudit, + recordAdminAccessAudit, + requireAdmin, +} from '../auth/admin.js'; +import { requireAuth } from '../auth/middleware.js'; +import { + countStaleBillingSyncWorkspaces, + getBillingTimelineAnomalySummary, + listStaleBillingSyncWorkspaces, +} from '../billing/repository.js'; +import { + hasAdminEmailAllowlistConfigured, + isAdminBootstrapEnabled, + isUsingDeprecatedBillingAdminFallback, +} from '../config/env.js'; +import { getDbPool } from '../db/pool.js'; +import { + countRecentFailedWebhookEvents, + listRecentFailedWebhookEvents, +} from '../payments/repository.js'; +import { requestWorkspaceBillingResyncByAdmin } from '../payments/service.js'; + +const auditListQuerySchema = z.object({ + actorEmail: z.string().trim().min(1).max(255).optional(), + action: z.enum(ADMIN_AUDIT_ACTION_VALUES).optional(), + workspaceId: z.string().uuid().optional(), + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + page: z.coerce.number().int().min(1).max(10_000).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), +}); + +const diagnosticsQuerySchema = z.object({ + windowDays: z.coerce.number().int().min(1).max(90).optional(), + staleSyncThresholdHours: z.coerce.number().int().min(1).max(720).optional(), + sampleLimit: z.coerce.number().int().min(1).max(50).optional(), +}); + +const billingResyncBodySchema = z.object({ + workspaceId: z.string().uuid(), + reason: z.string().trim().min(10).max(500), + confirmationText: z.literal('RESYNC'), + ticketRef: z.string().trim().min(1).max(120).optional(), +}); + +export const adminOpsRoutes: FastifyPluginAsync = async (app) => { + app.get('/admin/ops/audit', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { + try { + const query = auditListQuerySchema.parse(request.query ?? {}); + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 25; + const offset = (page - 1) * pageSize; + const db = getDbPool(); + + await recordAdminAccessAudit(db, { + actorUserId: request.authUser?.id, + actorEmail: request.authUser?.email, + route: request.routeOptions.url ?? request.url, + action: ADMIN_AUDIT_ACTIONS.ADMIN_OPS_AUDIT_LIST, + metadataJson: { + filters: { + actorEmail: query.actorEmail ?? null, + action: query.action ?? null, + workspaceId: query.workspaceId ?? null, + from: query.from ?? null, + to: query.to ?? null, + }, + page, + pageSize, + }, + }); + + const [items, total] = await Promise.all([ + listAdminAccessAudit(db, { + actorEmail: query.actorEmail, + action: query.action, + workspaceId: query.workspaceId, + from: query.from, + to: query.to, + limit: pageSize, + offset, + }), + countAdminAccessAudit(db, { + actorEmail: query.actorEmail, + action: query.action, + workspaceId: query.workspaceId, + from: query.from, + to: query.to, + }), + ]); + + const response: AdminAuditLogListResponse = { + items, + page, + pageSize, + total, + }; + + return response; + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin audit query.' }); + } + + request.log.error(error); + return reply.code(500).send({ error: 'Failed to load admin audit logs.' }); + } + }); + + app.get('/admin/ops/security-posture', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { + try { + const db = getDbPool(); + await recordAdminAccessAudit(db, { + actorUserId: request.authUser?.id, + actorEmail: request.authUser?.email, + route: request.routeOptions.url ?? request.url, + action: ADMIN_AUDIT_ACTIONS.ADMIN_OPS_SECURITY_POSTURE, + }); + + const [bootstrapRequired, activeApplicationAdminCount] = await Promise.all([ + isBootstrapRequired(db), + getActiveApplicationAdminCount(db), + ]); + + const response: AdminSecurityPostureResponse = { + bootstrapRequired, + bootstrapEnabled: isAdminBootstrapEnabled(), + activeApplicationAdminCount, + adminAllowlistConfigured: hasAdminEmailAllowlistConfigured(), + usingDeprecatedBillingAdminFallback: isUsingDeprecatedBillingAdminFallback(), + }; + + return response; + } catch (error) { + request.log.error(error); + return reply.code(500).send({ error: 'Failed to load admin security posture.' }); + } + }); + + app.get('/admin/ops/diagnostics', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { + try { + const query = diagnosticsQuerySchema.parse(request.query ?? {}); + const windowDays = query.windowDays ?? 7; + const staleSyncThresholdHours = query.staleSyncThresholdHours ?? 24; + const sampleLimit = query.sampleLimit ?? 10; + const db = getDbPool(); + + await recordAdminAccessAudit(db, { + actorUserId: request.authUser?.id, + actorEmail: request.authUser?.email, + route: request.routeOptions.url ?? request.url, + action: ADMIN_AUDIT_ACTIONS.ADMIN_OPS_DIAGNOSTICS, + metadataJson: { + windowDays, + staleSyncThresholdHours, + sampleLimit, + }, + }); + + const [failedWebhooksCount, staleBillingSyncCount, failedWebhooks, staleBillingSync, timelineAnomalies] = await Promise.all([ + countRecentFailedWebhookEvents(db, windowDays), + countStaleBillingSyncWorkspaces(db, staleSyncThresholdHours), + listRecentFailedWebhookEvents(db, { sinceDays: windowDays, limit: sampleLimit }), + listStaleBillingSyncWorkspaces(db, { staleThresholdHours: staleSyncThresholdHours, limit: sampleLimit }), + getBillingTimelineAnomalySummary(db, { + windowDays, + staleThresholdHours: staleSyncThresholdHours, + paymentFailedThreshold: 2, + }), + ]); + + const response: AdminSupportDiagnosticsResponse = { + windowDays, + staleSyncThresholdHours, + failedWebhooks: { + count: failedWebhooksCount, + items: failedWebhooks.map((issue) => ({ + id: issue.id, + eventType: issue.eventType, + workspaceId: issue.workspaceId, + workspaceName: issue.workspaceName, + errorMessage: issue.errorMessage, + receivedAt: issue.receivedAt, + externalEventId: issue.externalEventId, + })), + }, + staleBillingSync: { + count: staleBillingSyncCount, + items: staleBillingSync, + }, + timelineAnomalies, + }; + + return response; + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin diagnostics query.' }); + } + + request.log.error(error); + return reply.code(500).send({ error: 'Failed to load admin diagnostics.' }); + } + }); + + app.post('/admin/mutations/billing/resync', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { + try { + const payload = billingResyncBodySchema.parse(request.body ?? {}) as AdminBillingResyncRequest; + const db = getDbPool(); + + const result = await requestWorkspaceBillingResyncByAdmin(db, { + workspaceId: payload.workspaceId, + }); + + await recordAdminAccessAudit(db, { + actorUserId: request.authUser?.id, + actorEmail: request.authUser?.email, + route: request.routeOptions.url ?? request.url, + action: ADMIN_AUDIT_ACTIONS.ADMIN_MUTATION_BILLING_RESYNC_REQUESTED, + targetWorkspaceId: payload.workspaceId, + metadataJson: { + reason: payload.reason, + ticketRef: payload.ticketRef?.trim() || null, + confirmationText: payload.confirmationText, + }, + }); + + const response: AdminBillingResyncResponse = { + accepted: true, + message: 'Billing resync was requested successfully.', + workspaceId: result.workspaceId, + externalSubscriptionRef: result.externalSubscriptionRef, + }; + + return reply.code(202).send(response); + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid billing resync payload.' }); + } + + request.log.error(error); + return reply.code(500).send({ error: error instanceof Error ? error.message : 'Failed to request billing resync.' }); + } + }); +}; diff --git a/server/src/routes/analytics.ts b/server/src/routes/analytics.ts index 7253067..2b26dcf 100644 --- a/server/src/routes/analytics.ts +++ b/server/src/routes/analytics.ts @@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { z, ZodError } from 'zod'; import { ensureWorkspaceForUser } from '../account/repository.js'; import { hydrateAuthUser, requireAuth } from '../auth/middleware.js'; -import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js'; +import { ADMIN_AUDIT_ACTIONS, recordAdminAccessAudit, requireAdmin } from '../auth/admin.js'; import { getDbPool } from '../db/pool.js'; import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js'; @@ -75,7 +75,7 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => { actorUserId: request.authUser?.id, actorEmail: request.authUser?.email, route: request.routeOptions.url ?? request.url, - action: 'analytics_summary', + action: ADMIN_AUDIT_ACTIONS.ANALYTICS_SUMMARY, metadataJson: { days: query.days ?? 30, }, diff --git a/server/src/routes/billing.ts b/server/src/routes/billing.ts index 7cd36aa..7aa4f6f 100644 --- a/server/src/routes/billing.ts +++ b/server/src/routes/billing.ts @@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { z, ZodError } from 'zod'; import { getDbPool } from '../db/pool.js'; import { requireAuth } from '../auth/middleware.js'; -import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js'; +import { ADMIN_AUDIT_ACTIONS, recordAdminAccessAudit, requireAdmin } from '../auth/admin.js'; import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js'; import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js'; import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js'; @@ -121,7 +121,7 @@ export const billingRoutes: FastifyPluginAsync = async (app) => { actorUserId: request.authUser?.id, actorEmail: request.authUser?.email, route: request.routeOptions.url ?? request.url, - action: 'billing_workspaces_list', + action: ADMIN_AUDIT_ACTIONS.BILLING_WORKSPACES_LIST, metadataJson: { query: query.query ?? null, limit: query.limit ?? 50, @@ -151,7 +151,7 @@ export const billingRoutes: FastifyPluginAsync = async (app) => { actorUserId: request.authUser?.id, actorEmail: request.authUser?.email, route: request.routeOptions.url ?? request.url, - action: 'billing_workspace_detail', + action: ADMIN_AUDIT_ACTIONS.BILLING_WORKSPACE_DETAIL, targetWorkspaceId: workspaceId, }); const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId); diff --git a/shared/types.ts b/shared/types.ts index 967affd..bba3143 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -62,6 +62,111 @@ export interface AdminApplicationAdminResponse { admin: ApplicationAdminSummary; } +export type AdminAuditAction = + | 'admin_access_list' + | 'admin_access_upsert' + | 'admin_access_status_changed' + | 'analytics_summary' + | 'billing_workspaces_list' + | 'billing_workspace_detail' + | 'bootstrap_admin_claimed' + | 'admin_ops_audit_list' + | 'admin_ops_security_posture' + | 'admin_ops_diagnostics' + | 'admin_mutation_billing_resync_requested'; + +export interface AdminAuditLogItem { + id: string; + actorUserId: string | null; + actorEmail: string | null; + route: string; + action: AdminAuditAction; + targetWorkspaceId: string | null; + targetWorkspaceName: string | null; + metadataJson: Record; + occurredAt: string; +} + +export interface AdminAuditLogFilters { + actorEmail?: string; + action?: AdminAuditAction; + workspaceId?: string; + from?: string; + to?: string; + page?: number; + pageSize?: number; +} + +export interface AdminAuditLogListResponse { + items: AdminAuditLogItem[]; + page: number; + pageSize: number; + total: number; +} + +export interface AdminSecurityPostureResponse { + bootstrapRequired: boolean; + bootstrapEnabled: boolean; + activeApplicationAdminCount: number; + adminAllowlistConfigured: boolean; + usingDeprecatedBillingAdminFallback: boolean; +} + +export interface AdminFailedWebhookIssue { + id: string; + eventType: string; + workspaceId: string | null; + workspaceName: string | null; + errorMessage: string | null; + receivedAt: string; + externalEventId: string; +} + +export interface AdminStaleBillingSyncIssue { + workspaceId: string; + workspaceName: string; + billingSyncStatus: BillingSyncStatus; + lastStripeSyncAt: string | null; + status: AccountBillingStatus; + planCode: PlanCode | null; + pendingPlanCode: PlanCode | null; + pendingPlanEffectiveAt: string | null; +} + +export interface AdminTimelineAnomalySummary { + repeatedPaymentFailedCount: number; + pendingPlanPastEffectiveCount: number; + staleSyncThresholdCount: number; +} + +export interface AdminSupportDiagnosticsResponse { + windowDays: number; + staleSyncThresholdHours: number; + failedWebhooks: { + count: number; + items: AdminFailedWebhookIssue[]; + }; + staleBillingSync: { + count: number; + items: AdminStaleBillingSyncIssue[]; + }; + timelineAnomalies: AdminTimelineAnomalySummary; +} + +export interface AdminBillingResyncRequest { + workspaceId: string; + reason: string; + confirmationText: string; + ticketRef?: string; +} + +export interface AdminBillingResyncResponse { + accepted: boolean; + message: string; + workspaceId: string; + externalSubscriptionRef: string; +} + export type WorkspaceType = 'personal' | 'company'; export type WorkspaceRole = 'owner' | 'member'; diff --git a/src/components/AdminPage.tsx b/src/components/AdminPage.tsx index 44a2846..023fae4 100644 --- a/src/components/AdminPage.tsx +++ b/src/components/AdminPage.tsx @@ -1,23 +1,71 @@ -import { BarChart3, Loader2, Search, ShieldCheck } from 'lucide-react'; +import { BarChart3, Loader2, Search, ShieldCheck, ShieldAlert } from 'lucide-react'; import { useEffect, useState } from 'react'; -import type { AdminAnalyticsSummary, ApplicationAdminSummary, BillingAdminWorkspaceDetail, BillingAdminWorkspaceSummary } from '../../shared/types'; +import type { + AdminAnalyticsSummary, + AdminAuditAction, + AdminAuditLogItem, + AdminSecurityPostureResponse, + AdminSupportDiagnosticsResponse, + ApplicationAdminSummary, + BillingAdminWorkspaceDetail, + BillingAdminWorkspaceSummary, +} from '../../shared/types'; import { formatBillingStatusLabel, formatDateLabel } from '../lib/billing-ui'; import { getAdminAnalyticsSummary, getAdminBillingWorkspaceDetail, + getAdminSecurityPosture, + getAdminSupportDiagnostics, + listAdminAuditLogs, listAdminBillingWorkspaces, listApplicationAdmins, + requestAdminBillingResync, updateApplicationAdminStatus, upsertApplicationAdmin, } from '../lib/admin'; -import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader } from './ui'; +import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, Select } from './ui'; const MIN_SUMMARY_DAYS = 7; const MAX_SUMMARY_DAYS = 90; const DEFAULT_SUMMARY_DAYS = 30; +const DEFAULT_AUDIT_PAGE_SIZE = 25; +const DEFAULT_DIAGNOSTICS_WINDOW_DAYS = 7; +const DEFAULT_STALE_THRESHOLD_HOURS = 24; + +const AUDIT_ACTION_OPTIONS: Array<{ value: AdminAuditAction; label: string }> = [ + { value: 'admin_access_list', label: 'Admin access list' }, + { value: 'admin_access_upsert', label: 'Admin access upsert' }, + { value: 'admin_access_status_changed', label: 'Admin access status changed' }, + { value: 'analytics_summary', label: 'Analytics summary' }, + { value: 'billing_workspaces_list', label: 'Billing workspaces list' }, + { value: 'billing_workspace_detail', label: 'Billing workspace detail' }, + { value: 'bootstrap_admin_claimed', label: 'Bootstrap admin claimed' }, + { value: 'admin_ops_audit_list', label: 'Admin ops audit list' }, + { value: 'admin_ops_security_posture', label: 'Admin ops security posture' }, + { value: 'admin_ops_diagnostics', label: 'Admin ops diagnostics' }, + { value: 'admin_mutation_billing_resync_requested', label: 'Admin mutation billing resync requested' }, +]; + +function formatAuditActionLabel(action: AdminAuditAction) { + const matched = AUDIT_ACTION_OPTIONS.find((option) => option.value === action); + return matched?.label ?? action; +} const clampSummaryDays = (value: number) => Math.min(MAX_SUMMARY_DAYS, Math.max(MIN_SUMMARY_DAYS, value)); +function toIsoDateTime(value: string) { + if (!value) { + return undefined; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return undefined; + } + + return parsed.toISOString(); +} + export function AdminPage() { const [summaryDays, setSummaryDays] = useState(DEFAULT_SUMMARY_DAYS); const [analyticsSummary, setAnalyticsSummary] = useState(null); @@ -40,6 +88,99 @@ export function AdminPage() { const [adminEmailSubmitting, setAdminEmailSubmitting] = useState(false); const [statusMutationAdminId, setStatusMutationAdminId] = useState(null); + const [securityPosture, setSecurityPosture] = useState(null); + const [securityLoading, setSecurityLoading] = useState(true); + const [securityError, setSecurityError] = useState(null); + + const [diagnostics, setDiagnostics] = useState(null); + const [diagnosticsLoading, setDiagnosticsLoading] = useState(true); + const [diagnosticsError, setDiagnosticsError] = useState(null); + + const [auditItems, setAuditItems] = useState([]); + const [auditLoading, setAuditLoading] = useState(true); + const [auditError, setAuditError] = useState(null); + const [auditPage, setAuditPage] = useState(1); + const [auditTotal, setAuditTotal] = useState(0); + const [auditActorEmail, setAuditActorEmail] = useState(''); + const [auditAction, setAuditAction] = useState(''); + const [auditWorkspaceId, setAuditWorkspaceId] = useState(''); + const [auditFrom, setAuditFrom] = useState(''); + const [auditTo, setAuditTo] = useState(''); + + const [mutationWorkspaceId, setMutationWorkspaceId] = useState(''); + const [mutationReason, setMutationReason] = useState(''); + const [mutationTicketRef, setMutationTicketRef] = useState(''); + const [mutationConfirmation, setMutationConfirmation] = useState(''); + const [mutationSubmitting, setMutationSubmitting] = useState(false); + const [mutationFeedback, setMutationFeedback] = useState(null); + const [mutationError, setMutationError] = useState(null); + + const loadAuditLogs = async ( + page: number, + overrides?: Partial<{ actorEmail: string; action: AdminAuditAction | ''; workspaceId: string; from: string; to: string }>, + ) => { + const actorEmail = overrides?.actorEmail ?? auditActorEmail; + const action = overrides?.action ?? auditAction; + const workspaceId = overrides?.workspaceId ?? auditWorkspaceId; + const from = overrides?.from ?? auditFrom; + const to = overrides?.to ?? auditTo; + + setAuditLoading(true); + setAuditError(null); + + try { + const response = await listAdminAuditLogs({ + actorEmail: actorEmail.trim() || undefined, + action: action || undefined, + workspaceId: workspaceId.trim() || undefined, + from: toIsoDateTime(from), + to: toIsoDateTime(to), + page, + pageSize: DEFAULT_AUDIT_PAGE_SIZE, + }); + + setAuditItems(response.items); + setAuditTotal(response.total); + setAuditPage(response.page); + } catch (error) { + setAuditError(error instanceof Error ? error.message : 'Failed to load admin audit logs.'); + } finally { + setAuditLoading(false); + } + }; + + const loadSecurityPosture = async () => { + setSecurityLoading(true); + setSecurityError(null); + + try { + const response = await getAdminSecurityPosture(); + setSecurityPosture(response); + } catch (error) { + setSecurityError(error instanceof Error ? error.message : 'Failed to load security posture.'); + } finally { + setSecurityLoading(false); + } + }; + + const loadDiagnostics = async () => { + setDiagnosticsLoading(true); + setDiagnosticsError(null); + + try { + const response = await getAdminSupportDiagnostics({ + windowDays: DEFAULT_DIAGNOSTICS_WINDOW_DAYS, + staleSyncThresholdHours: DEFAULT_STALE_THRESHOLD_HOURS, + sampleLimit: 10, + }); + setDiagnostics(response); + } catch (error) { + setDiagnosticsError(error instanceof Error ? error.message : 'Failed to load support diagnostics.'); + } finally { + setDiagnosticsLoading(false); + } + }; + useEffect(() => { let isMounted = true; @@ -52,10 +193,17 @@ export function AdminPage() { setAdminsError(null); try { - const [summary, workspaceResponse, adminResponse] = await Promise.all([ + const [summary, workspaceResponse, adminResponse, securityResponse, diagnosticsResponse, auditResponse] = await Promise.all([ getAdminAnalyticsSummary(DEFAULT_SUMMARY_DAYS), listAdminBillingWorkspaces(), listApplicationAdmins(), + getAdminSecurityPosture(), + getAdminSupportDiagnostics({ + windowDays: DEFAULT_DIAGNOSTICS_WINDOW_DAYS, + staleSyncThresholdHours: DEFAULT_STALE_THRESHOLD_HOURS, + sampleLimit: 10, + }), + listAdminAuditLogs({ page: 1, pageSize: DEFAULT_AUDIT_PAGE_SIZE }), ]); if (!isMounted) { @@ -65,6 +213,11 @@ export function AdminPage() { setAnalyticsSummary(summary); setWorkspaces(workspaceResponse.workspaces); setAdmins(adminResponse.admins); + setSecurityPosture(securityResponse); + setDiagnostics(diagnosticsResponse); + setAuditItems(auditResponse.items); + setAuditTotal(auditResponse.total); + setAuditPage(auditResponse.page); } catch (error) { if (!isMounted) { return; @@ -74,11 +227,17 @@ export function AdminPage() { setSummaryError(message); setWorkspacesError(message); setAdminsError(message); + setSecurityError(message); + setDiagnosticsError(message); + setAuditError(message); } finally { if (isMounted) { setSummaryLoading(false); setWorkspacesLoading(false); setAdminsLoading(false); + setSecurityLoading(false); + setDiagnosticsLoading(false); + setAuditLoading(false); } } }; @@ -161,6 +320,7 @@ export function AdminPage() { setAdminMutationFeedback(`Admin access is active for ${response.admin.email}.`); setAdminEmailInput(''); await refreshAdmins(); + await loadSecurityPosture(); } catch (error) { setAdminMutationError(error instanceof Error ? error.message : 'Failed to add or reactivate admin.'); } finally { @@ -177,6 +337,7 @@ export function AdminPage() { const response = await updateApplicationAdminStatus(adminId, status); setAdminMutationFeedback(`${response.admin.email} is now ${status}.`); await refreshAdmins(); + await loadSecurityPosture(); } catch (error) { setAdminMutationError(error instanceof Error ? error.message : 'Failed to update admin status.'); } finally { @@ -184,15 +345,125 @@ export function AdminPage() { } }; + const handleRequestBillingResync = async () => { + setMutationError(null); + setMutationFeedback(null); + setMutationSubmitting(true); + + try { + const response = await requestAdminBillingResync({ + workspaceId: mutationWorkspaceId.trim(), + reason: mutationReason.trim(), + confirmationText: mutationConfirmation.trim(), + ticketRef: mutationTicketRef.trim() || undefined, + }); + + setMutationFeedback(response.message); + + if (selectedWorkspace?.summary.workspaceId === mutationWorkspaceId.trim()) { + await handleLoadWorkspaceDetail(mutationWorkspaceId.trim()); + } + + await loadDiagnostics(); + await loadAuditLogs(1); + } catch (error) { + setMutationError(error instanceof Error ? error.message : 'Failed to request billing resync.'); + } finally { + setMutationSubmitting(false); + } + }; + + const totalAuditPages = Math.max(1, Math.ceil(auditTotal / DEFAULT_AUDIT_PAGE_SIZE)); + const canSubmitMutation = + mutationWorkspaceId.trim().length > 0 + && mutationReason.trim().length >= 10 + && mutationConfirmation.trim() === 'RESYNC' + && !mutationSubmitting; + return ( + +
+
+
+ +
+
+

Safe Mutations (Pilot)

+

Request billing resync for a workspace

+
+
+
+ +

+ This triggers a Stripe subscription re-sync for one workspace. It does not directly change plan pricing configuration. +

+ +
+
+ Workspace ID + setMutationWorkspaceId(event.target.value)} + placeholder="Workspace UUID" + /> +
+
+ Ticket reference (optional) + setMutationTicketRef(event.target.value)} + placeholder="SUP-1234" + /> +
+
+ +
+ Reason (required) + setMutationReason(event.target.value)} + placeholder="Explain why this resync is needed." + /> +
+ +
+ Type RESYNC to confirm + setMutationConfirmation(event.target.value)} + placeholder="RESYNC" + /> +
+ + {mutationError ? {mutationError} : null} + {mutationFeedback ? {mutationFeedback} : null} + +
+ + {selectedWorkspace ? ( + + ) : null} +
+
+
@@ -263,6 +534,337 @@ export function AdminPage() {
+ +
+
+
+ +
+
+

Security Posture

+

Bootstrap and admin-allowlist checks

+
+
+ +
+ + {securityError ? {securityError} : null} + {securityLoading && !securityPosture ? : null} + + {securityPosture ? ( +
+
+
+

Bootstrap Required

+

{securityPosture.bootstrapRequired ? 'Yes' : 'No'}

+
+
+

Bootstrap Enabled

+

{securityPosture.bootstrapEnabled ? 'Yes' : 'No'}

+
+
+

Active App Admins

+

{securityPosture.activeApplicationAdminCount}

+
+
+ + {securityPosture.usingDeprecatedBillingAdminFallback ? ( + +

Set `ADMIN_EMAILS` and stop relying on `BILLING_ADMIN_EMAILS` fallback.

+
+ ) : null} + + {!securityPosture.adminAllowlistConfigured ? ( + +

Configure `ADMIN_EMAILS` for emergency access and recovery playbooks.

+
+ ) : null} + + {securityPosture.bootstrapEnabled ? ( + +

After first-run setup, disable bootstrap and rotate `ADMIN_BOOTSTRAP_TOKEN`.

+
+ ) : null} + +
+

Hardening checklist

+
    +
  • - Disable bootstrap once initial admin setup is complete.
  • +
  • - Rotate the bootstrap token and store it in secrets management.
  • +
  • - Keep at least two active application admins to reduce lockout risk.
  • +
+
+
+ ) : null} +
+ + +
+
+
+ +
+
+

Support Diagnostics

+

Webhook failures, sync health, and timeline anomalies

+
+
+ +
+ + {diagnosticsError ? {diagnosticsError} : null} + {diagnosticsLoading && !diagnostics ? : null} + + {diagnostics ? ( +
+
+ + + +
+ +
+
+
+

Failed webhook samples

+ {diagnostics.failedWebhooks.count} total +
+
+ {diagnostics.failedWebhooks.items.length === 0 ?

No failed webhook events in window.

: null} + {diagnostics.failedWebhooks.items.map((issue) => ( +
+
+
+

{issue.eventType}

+

{issue.workspaceName || 'Unknown workspace'} - {formatDateLabel(issue.receivedAt)}

+

{issue.errorMessage || 'No error message provided.'}

+
+ {issue.workspaceId ? ( + + ) : null} +
+
+ ))} +
+
+ +
+
+

Stale sync samples

+ {diagnostics.staleBillingSync.count} total +
+
+ {diagnostics.staleBillingSync.items.length === 0 ?

No stale sync accounts in window.

: null} + {diagnostics.staleBillingSync.items.map((issue) => ( +
+
+
+

{issue.workspaceName}

+

{issue.planCode || 'No plan'} - {formatBillingStatusLabel(issue.status)}

+

Last sync: {formatDateLabel(issue.lastStripeSyncAt)}

+
+ +
+
+ ))} +
+
+
+ +
+

Timeline anomaly counters ({diagnostics.windowDays}-day window)

+
+
+ Repeated payment failures: {diagnostics.timelineAnomalies.repeatedPaymentFailedCount} +
+
+ Pending plan past effective date: {diagnostics.timelineAnomalies.pendingPlanPastEffectiveCount} +
+
+ Stale sync threshold breaches: {diagnostics.timelineAnomalies.staleSyncThresholdCount} +
+
+
+
+ ) : null} +
+ + +
+
+
+ +
+
+

Admin Audit Explorer

+

Route and support action history

+
+
+ +
+ +
+
+ Actor email + setAuditActorEmail(event.target.value)} placeholder="ops@company.com" /> +
+
+ Action + +
+
+ Workspace ID + setAuditWorkspaceId(event.target.value)} placeholder="uuid" /> +
+
+ From + setAuditFrom(event.target.value)} /> +
+
+ To + setAuditTo(event.target.value)} /> +
+
+ +
+ + +
+ + {auditError ? {auditError} : null} + +
+ {auditLoading && auditItems.length === 0 ? : null} + {!auditLoading && auditItems.length === 0 ? ( +

+ No audit entries found for the selected filters. +

+ ) : null} + + {auditItems.length > 0 ? ( + + + + + + + + + + + + {auditItems.map((item) => ( + + + + + + + + ))} + +
OccurredActorActionRouteWorkspace
{formatDateLabel(item.occurredAt)}{item.actorEmail || 'Unknown actor'} + + {formatAuditActionLabel(item.action)} + + {item.route}{item.targetWorkspaceName || item.targetWorkspaceId || 'N/A'}
+ ) : null} +
+ +
+

+ Showing page {auditPage} of {totalAuditPages} ({auditTotal} total entries) +

+
+ + +
+
+
+
@@ -413,6 +1015,15 @@ export function AdminPage() { ); } +function DiagnosticsMetricCard({ title, value }: { title: string; value: number }) { + return ( +
+

{title}

+

{value}

+
+ ); +} + function MetricBucketCard({ title, buckets }: { title: string; buckets: Array<{ key: string; count: number }> }) { return (
diff --git a/src/lib/admin.ts b/src/lib/admin.ts index 4e0540c..5afbbff 100644 --- a/src/lib/admin.ts +++ b/src/lib/admin.ts @@ -1,7 +1,13 @@ import type { + AdminAuditLogFilters, + AdminAuditLogListResponse, + AdminBillingResyncRequest, + AdminBillingResyncResponse, AdminApplicationAdminResponse, AdminApplicationAdminsListResponse, AdminAnalyticsSummary, + AdminSecurityPostureResponse, + AdminSupportDiagnosticsResponse, ApplicationAdminStatus, BillingAdminWorkspaceDetailResponse, BillingAdminWorkspaceListResponse, @@ -40,3 +46,68 @@ export async function updateApplicationAdminStatus(adminId: string, status: Appl body: JSON.stringify({ status }), }); } + +export async function listAdminAuditLogs(filters: AdminAuditLogFilters = {}) { + const params = new URLSearchParams(); + + if (filters.actorEmail?.trim()) { + params.set('actorEmail', filters.actorEmail.trim()); + } + + if (filters.action?.trim()) { + params.set('action', filters.action.trim()); + } + + if (filters.workspaceId?.trim()) { + params.set('workspaceId', filters.workspaceId.trim()); + } + + if (filters.from) { + params.set('from', filters.from); + } + + if (filters.to) { + params.set('to', filters.to); + } + + if (typeof filters.page === 'number') { + params.set('page', String(filters.page)); + } + + if (typeof filters.pageSize === 'number') { + params.set('pageSize', String(filters.pageSize)); + } + + const queryString = params.toString(); + return apiRequest(`/admin/ops/audit${queryString ? `?${queryString}` : ''}`); +} + +export async function getAdminSecurityPosture() { + return apiRequest('/admin/ops/security-posture'); +} + +export async function getAdminSupportDiagnostics(query?: { windowDays?: number; staleSyncThresholdHours?: number; sampleLimit?: number }) { + const params = new URLSearchParams(); + + if (typeof query?.windowDays === 'number') { + params.set('windowDays', String(query.windowDays)); + } + + if (typeof query?.staleSyncThresholdHours === 'number') { + params.set('staleSyncThresholdHours', String(query.staleSyncThresholdHours)); + } + + if (typeof query?.sampleLimit === 'number') { + params.set('sampleLimit', String(query.sampleLimit)); + } + + const queryString = params.toString(); + return apiRequest(`/admin/ops/diagnostics${queryString ? `?${queryString}` : ''}`); +} + +export async function requestAdminBillingResync(payload: AdminBillingResyncRequest) { + return apiRequest('/admin/mutations/billing/resync', { + method: 'POST', + body: JSON.stringify(payload), + }); +}