Public Access
1
0

feat: complete admin phase C and add safe mutation pilot

This commit is contained in:
pguerrerox
2026-05-28 12:46:06 +00:00
parent c58945353d
commit ce49497a6a
17 changed files with 1568 additions and 21 deletions
+30
View File
@@ -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:
+62 -4
View File
@@ -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.
+8
View File
@@ -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));
+2
View File
@@ -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;
}
+151 -1
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
occurredAt?: string;
@@ -222,3 +274,101 @@ export async function recordAdminAccessAudit(db: DbClient, payload: AdminAccessA
],
);
}
function buildAdminAccessAuditWhere(filters: Omit<AdminAccessAuditListFilters, 'limit' | 'offset'>) {
const clauses: string[] = [];
const params: Array<string> = [];
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<AdminAccessAuditListFilters, 'limit' | 'offset'>,
): Promise<number> {
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<AdminAccessAuditSummary[]> {
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<AdminAccessAuditRow>(
`
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);
}
+140
View File
@@ -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<BillingAccountRecord | null> {
const result = await db.query<BillingAccountRow>(
`
@@ -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<BillingStaleSyncWorkspaceSummary[]> {
const staleThresholdHours = input.staleThresholdHours ?? 24;
const limit = input.limit ?? 10;
const result = await db.query<BillingStaleSyncWorkspaceRow>(
`
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<number> {
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<BillingTimelineAnomalySummary> {
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<BillingAdminWorkspaceSummaryRow>(
`
+13
View File
@@ -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();
+64
View File
@@ -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<BillingFailedWebhookEventSummary[]> {
const sinceDays = input.sinceDays ?? 7;
const limit = input.limit ?? 10;
const result = await db.query<BillingFailedWebhookEventRow>(
`
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<number> {
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,
+30 -1
View File
@@ -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':
+4 -3
View File
@@ -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,
+8 -2
View File
@@ -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,
},
+259
View File
@@ -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.' });
}
});
};
+2 -2
View File
@@ -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,
},
+3 -3
View File
@@ -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);
+105
View File
@@ -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<string, unknown>;
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';
+616 -5
View File
@@ -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<AdminAnalyticsSummary | null>(null);
@@ -40,6 +88,99 @@ export function AdminPage() {
const [adminEmailSubmitting, setAdminEmailSubmitting] = useState(false);
const [statusMutationAdminId, setStatusMutationAdminId] = useState<string | null>(null);
const [securityPosture, setSecurityPosture] = useState<AdminSecurityPostureResponse | null>(null);
const [securityLoading, setSecurityLoading] = useState(true);
const [securityError, setSecurityError] = useState<string | null>(null);
const [diagnostics, setDiagnostics] = useState<AdminSupportDiagnosticsResponse | null>(null);
const [diagnosticsLoading, setDiagnosticsLoading] = useState(true);
const [diagnosticsError, setDiagnosticsError] = useState<string | null>(null);
const [auditItems, setAuditItems] = useState<AdminAuditLogItem[]>([]);
const [auditLoading, setAuditLoading] = useState(true);
const [auditError, setAuditError] = useState<string | null>(null);
const [auditPage, setAuditPage] = useState(1);
const [auditTotal, setAuditTotal] = useState(0);
const [auditActorEmail, setAuditActorEmail] = useState('');
const [auditAction, setAuditAction] = useState<AdminAuditAction | ''>('');
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<string | null>(null);
const [mutationError, setMutationError] = useState<string | null>(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 (
<PageShell>
<PageContainer>
<SectionHeader
eyebrow="Admin"
title="Admin Console"
description="Visibility into pricing analytics, admin access identities, and workspace billing state."
description="Visibility into pricing analytics, admin access identities, security posture, support diagnostics, and workspace billing state."
/>
<Card className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
<ShieldAlert className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Safe Mutations (Pilot)</p>
<h2 className="text-lg font-semibold text-stone-950">Request billing resync for a workspace</h2>
</div>
</div>
</div>
<p className="mt-3 text-sm text-stone-600">
This triggers a Stripe subscription re-sync for one workspace. It does not directly change plan pricing configuration.
</p>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<FieldLabel>Workspace ID</FieldLabel>
<Input
value={mutationWorkspaceId}
onChange={(event) => setMutationWorkspaceId(event.target.value)}
placeholder="Workspace UUID"
/>
</div>
<div>
<FieldLabel>Ticket reference (optional)</FieldLabel>
<Input
value={mutationTicketRef}
onChange={(event) => setMutationTicketRef(event.target.value)}
placeholder="SUP-1234"
/>
</div>
</div>
<div className="mt-3">
<FieldLabel>Reason (required)</FieldLabel>
<Input
value={mutationReason}
onChange={(event) => setMutationReason(event.target.value)}
placeholder="Explain why this resync is needed."
/>
</div>
<div className="mt-3">
<FieldLabel>Type RESYNC to confirm</FieldLabel>
<Input
value={mutationConfirmation}
onChange={(event) => setMutationConfirmation(event.target.value)}
placeholder="RESYNC"
/>
</div>
{mutationError ? <Alert className="mt-4" variant="error">{mutationError}</Alert> : null}
{mutationFeedback ? <Alert className="mt-4" variant="success">{mutationFeedback}</Alert> : null}
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => void handleRequestBillingResync()} disabled={!canSubmitMutation}>
{mutationSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Request billing resync
</Button>
{selectedWorkspace ? (
<Button
type="button"
variant="subtle"
onClick={() => setMutationWorkspaceId(selectedWorkspace.summary.workspaceId)}
disabled={mutationSubmitting}
>
Use selected workspace
</Button>
) : null}
</div>
</Card>
<Card className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
@@ -263,6 +534,337 @@ export function AdminPage() {
</div>
</Card>
<Card className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
<ShieldAlert className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Security Posture</p>
<h2 className="text-lg font-semibold text-stone-950">Bootstrap and admin-allowlist checks</h2>
</div>
</div>
<Button type="button" variant="secondary" onClick={() => void loadSecurityPosture()} disabled={securityLoading}>
{securityLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Refresh
</Button>
</div>
{securityError ? <Alert className="mt-4" variant="error">{securityError}</Alert> : null}
{securityLoading && !securityPosture ? <LoadingState message="Loading security posture..." /> : null}
{securityPosture ? (
<div className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="rounded-xl bg-stone-50 p-3">
<p className="text-xs uppercase tracking-[0.14em] text-stone-500">Bootstrap Required</p>
<p className="mt-1 font-semibold text-stone-900">{securityPosture.bootstrapRequired ? 'Yes' : 'No'}</p>
</div>
<div className="rounded-xl bg-stone-50 p-3">
<p className="text-xs uppercase tracking-[0.14em] text-stone-500">Bootstrap Enabled</p>
<p className="mt-1 font-semibold text-stone-900">{securityPosture.bootstrapEnabled ? 'Yes' : 'No'}</p>
</div>
<div className="rounded-xl bg-stone-50 p-3">
<p className="text-xs uppercase tracking-[0.14em] text-stone-500">Active App Admins</p>
<p className="mt-1 font-semibold text-stone-900">{securityPosture.activeApplicationAdminCount}</p>
</div>
</div>
{securityPosture.usingDeprecatedBillingAdminFallback ? (
<Alert variant="error" title="Deprecated allowlist fallback active">
<p>Set `ADMIN_EMAILS` and stop relying on `BILLING_ADMIN_EMAILS` fallback.</p>
</Alert>
) : null}
{!securityPosture.adminAllowlistConfigured ? (
<Alert variant="info" title="Admin allowlist not configured">
<p>Configure `ADMIN_EMAILS` for emergency access and recovery playbooks.</p>
</Alert>
) : null}
{securityPosture.bootstrapEnabled ? (
<Alert variant="error" title="Bootstrap still enabled">
<p>After first-run setup, disable bootstrap and rotate `ADMIN_BOOTSTRAP_TOKEN`.</p>
</Alert>
) : null}
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-sm text-stone-700">
<p className="font-semibold text-stone-900">Hardening checklist</p>
<ul className="mt-2 space-y-1">
<li>- Disable bootstrap once initial admin setup is complete.</li>
<li>- Rotate the bootstrap token and store it in secrets management.</li>
<li>- Keep at least two active application admins to reduce lockout risk.</li>
</ul>
</div>
</div>
) : null}
</Card>
<Card className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
<ShieldAlert className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Support Diagnostics</p>
<h2 className="text-lg font-semibold text-stone-950">Webhook failures, sync health, and timeline anomalies</h2>
</div>
</div>
<Button type="button" variant="secondary" onClick={() => void loadDiagnostics()} disabled={diagnosticsLoading}>
{diagnosticsLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Refresh
</Button>
</div>
{diagnosticsError ? <Alert className="mt-4" variant="error">{diagnosticsError}</Alert> : null}
{diagnosticsLoading && !diagnostics ? <LoadingState message="Loading support diagnostics..." /> : null}
{diagnostics ? (
<div className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<DiagnosticsMetricCard title="Failed webhooks" value={diagnostics.failedWebhooks.count} />
<DiagnosticsMetricCard title="Stale sync accounts" value={diagnostics.staleBillingSync.count} />
<DiagnosticsMetricCard
title="Timeline anomalies"
value={
diagnostics.timelineAnomalies.repeatedPaymentFailedCount
+ diagnostics.timelineAnomalies.pendingPlanPastEffectiveCount
+ diagnostics.timelineAnomalies.staleSyncThresholdCount
}
/>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-stone-200 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold uppercase tracking-[0.14em] text-stone-500">Failed webhook samples</p>
<Badge>{diagnostics.failedWebhooks.count} total</Badge>
</div>
<div className="mt-3 space-y-2">
{diagnostics.failedWebhooks.items.length === 0 ? <p className="text-sm text-stone-600">No failed webhook events in window.</p> : null}
{diagnostics.failedWebhooks.items.map((issue) => (
<div key={issue.id} className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold text-stone-900">{issue.eventType}</p>
<p className="text-stone-600">{issue.workspaceName || 'Unknown workspace'} - {formatDateLabel(issue.receivedAt)}</p>
<p className="mt-1 text-xs text-stone-500">{issue.errorMessage || 'No error message provided.'}</p>
</div>
{issue.workspaceId ? (
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void handleLoadWorkspaceDetail(issue.workspaceId!)}
>
Open
</Button>
) : null}
</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-stone-200 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold uppercase tracking-[0.14em] text-stone-500">Stale sync samples</p>
<Badge>{diagnostics.staleBillingSync.count} total</Badge>
</div>
<div className="mt-3 space-y-2">
{diagnostics.staleBillingSync.items.length === 0 ? <p className="text-sm text-stone-600">No stale sync accounts in window.</p> : null}
{diagnostics.staleBillingSync.items.map((issue) => (
<div key={issue.workspaceId} className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold text-stone-900">{issue.workspaceName}</p>
<p className="text-stone-600">{issue.planCode || 'No plan'} - {formatBillingStatusLabel(issue.status)}</p>
<p className="mt-1 text-xs text-stone-500">Last sync: {formatDateLabel(issue.lastStripeSyncAt)}</p>
</div>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void handleLoadWorkspaceDetail(issue.workspaceId)}
>
Open
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div className="rounded-2xl border border-stone-200 p-4 text-sm">
<p className="font-semibold text-stone-900">Timeline anomaly counters ({diagnostics.windowDays}-day window)</p>
<div className="mt-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div className="rounded-xl bg-stone-50 p-3 text-stone-700">
Repeated payment failures: <span className="font-semibold text-stone-900">{diagnostics.timelineAnomalies.repeatedPaymentFailedCount}</span>
</div>
<div className="rounded-xl bg-stone-50 p-3 text-stone-700">
Pending plan past effective date: <span className="font-semibold text-stone-900">{diagnostics.timelineAnomalies.pendingPlanPastEffectiveCount}</span>
</div>
<div className="rounded-xl bg-stone-50 p-3 text-stone-700">
Stale sync threshold breaches: <span className="font-semibold text-stone-900">{diagnostics.timelineAnomalies.staleSyncThresholdCount}</span>
</div>
</div>
</div>
</div>
) : null}
</Card>
<Card className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Admin Audit Explorer</p>
<h2 className="text-lg font-semibold text-stone-950">Route and support action history</h2>
</div>
</div>
<Button type="button" variant="secondary" onClick={() => void loadAuditLogs(auditPage)} disabled={auditLoading}>
{auditLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Refresh
</Button>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
<div>
<FieldLabel>Actor email</FieldLabel>
<Input value={auditActorEmail} onChange={(event) => setAuditActorEmail(event.target.value)} placeholder="ops@company.com" />
</div>
<div>
<FieldLabel>Action</FieldLabel>
<Select value={auditAction} onChange={(event) => setAuditAction(event.target.value as AdminAuditAction | '')}>
<option value="">All actions</option>
{AUDIT_ACTION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</Select>
</div>
<div>
<FieldLabel>Workspace ID</FieldLabel>
<Input value={auditWorkspaceId} onChange={(event) => setAuditWorkspaceId(event.target.value)} placeholder="uuid" />
</div>
<div>
<FieldLabel>From</FieldLabel>
<Input type="datetime-local" value={auditFrom} onChange={(event) => setAuditFrom(event.target.value)} />
</div>
<div>
<FieldLabel>To</FieldLabel>
<Input type="datetime-local" value={auditTo} onChange={(event) => setAuditTo(event.target.value)} />
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<Button
type="button"
onClick={() => {
void loadAuditLogs(1);
}}
disabled={auditLoading}
>
Apply filters
</Button>
<Button
type="button"
variant="subtle"
onClick={() => {
setAuditActorEmail('');
setAuditAction('');
setAuditWorkspaceId('');
setAuditFrom('');
setAuditTo('');
void loadAuditLogs(1, {
actorEmail: '',
action: '',
workspaceId: '',
from: '',
to: '',
});
}}
disabled={auditLoading}
>
Clear
</Button>
</div>
{auditError ? <Alert className="mt-4" variant="error">{auditError}</Alert> : null}
<div className="mt-4 overflow-x-auto">
{auditLoading && auditItems.length === 0 ? <LoadingState message="Loading admin audit logs..." /> : null}
{!auditLoading && auditItems.length === 0 ? (
<p className="rounded-2xl border border-dashed border-stone-200 bg-stone-50 px-4 py-5 text-sm text-stone-600">
No audit entries found for the selected filters.
</p>
) : null}
{auditItems.length > 0 ? (
<table className="w-full min-w-[920px] text-left text-sm">
<thead>
<tr className="border-b border-stone-200 text-xs uppercase tracking-[0.14em] text-stone-500">
<th className="px-3 py-2">Occurred</th>
<th className="px-3 py-2">Actor</th>
<th className="px-3 py-2">Action</th>
<th className="px-3 py-2">Route</th>
<th className="px-3 py-2">Workspace</th>
</tr>
</thead>
<tbody>
{auditItems.map((item) => (
<tr key={item.id} className="border-b border-stone-100">
<td className="px-3 py-2 text-stone-700">{formatDateLabel(item.occurredAt)}</td>
<td className="px-3 py-2 text-stone-700">{item.actorEmail || 'Unknown actor'}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-stone-100 px-2 py-1 text-xs font-semibold text-stone-700" title={item.action}>
{formatAuditActionLabel(item.action)}
</span>
</td>
<td className="px-3 py-2 text-stone-700">{item.route}</td>
<td className="px-3 py-2 text-stone-700">{item.targetWorkspaceName || item.targetWorkspaceId || 'N/A'}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-stone-600">
<p>
Showing page {auditPage} of {totalAuditPages} ({auditTotal} total entries)
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={auditLoading || auditPage <= 1}
onClick={() => {
void loadAuditLogs(auditPage - 1);
}}
>
Previous
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={auditLoading || auditPage >= totalAuditPages}
onClick={() => {
void loadAuditLogs(auditPage + 1);
}}
>
Next
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
@@ -413,6 +1015,15 @@ export function AdminPage() {
);
}
function DiagnosticsMetricCard({ title, value }: { title: string; value: number }) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<p className="text-xs uppercase tracking-[0.14em] text-stone-500">{title}</p>
<p className="mt-1 text-2xl font-semibold tracking-tight text-stone-900">{value}</p>
</div>
);
}
function MetricBucketCard({ title, buckets }: { title: string; buckets: Array<{ key: string; count: number }> }) {
return (
<div className="rounded-2xl border border-stone-200 p-4">
+71
View File
@@ -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<AdminAuditLogListResponse>(`/admin/ops/audit${queryString ? `?${queryString}` : ''}`);
}
export async function getAdminSecurityPosture() {
return apiRequest<AdminSecurityPostureResponse>('/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<AdminSupportDiagnosticsResponse>(`/admin/ops/diagnostics${queryString ? `?${queryString}` : ''}`);
}
export async function requestAdminBillingResync(payload: AdminBillingResyncRequest) {
return apiRequest<AdminBillingResyncResponse>('/admin/mutations/billing/resync', {
method: 'POST',
body: JSON.stringify(payload),
});
}