feat: complete admin phase C and add safe mutation pilot
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
`
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user