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.
|
- 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.
|
- 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
|
## Docker Deployment
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env` and set at least:
|
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): 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): 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.
|
- [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).
|
- [x] Phase C execution split (implementation sequencing)
|
||||||
- [ ] Phase C (Audit & Support Operations): Expose bootstrap/security posture checks in admin UI (bootstrap enabled state, fallback allowlist usage warnings).
|
- [x] Sub-step 1 (Backend foundations): ship admin audit list API + security posture API + diagnostics aggregate API, plus shared types/client contracts.
|
||||||
- [ ] Phase C (Audit & Support Operations): Add support-oriented diagnostics widgets (recent webhook issues, billing sync errors, timeline anomalies).
|
- [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.
|
- [ ] 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.
|
- [ ] 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
|
## 13) [DEFER] Operational Enforcement Follow-Up
|
||||||
- [ ] Add queue prioritization by plan tier.
|
- [ ] 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 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 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 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 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 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 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 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.
|
- [ ] 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 { analyticsRoutes } from './routes/analytics.js';
|
||||||
import { adminBootstrapRoutes } from './routes/admin-bootstrap.js';
|
import { adminBootstrapRoutes } from './routes/admin-bootstrap.js';
|
||||||
import { adminAccessRoutes } from './routes/admin-access.js';
|
import { adminAccessRoutes } from './routes/admin-access.js';
|
||||||
|
import { adminOpsRoutes } from './routes/admin-ops.js';
|
||||||
|
|
||||||
function parseAllowedOrigins(rawOrigins: string) {
|
function parseAllowedOrigins(rawOrigins: string) {
|
||||||
return rawOrigins
|
return rawOrigins
|
||||||
@@ -60,6 +61,7 @@ export async function buildApp() {
|
|||||||
await app.register(analyticsRoutes, { prefix: '/api' });
|
await app.register(analyticsRoutes, { prefix: '/api' });
|
||||||
await app.register(adminBootstrapRoutes, { prefix: '/api' });
|
await app.register(adminBootstrapRoutes, { prefix: '/api' });
|
||||||
await app.register(adminAccessRoutes, { prefix: '/api' });
|
await app.register(adminAccessRoutes, { prefix: '/api' });
|
||||||
|
await app.register(adminOpsRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-1
@@ -6,6 +6,24 @@ import { getDbPool } from '../db/pool.js';
|
|||||||
|
|
||||||
type DbClient = Pool | PoolClient;
|
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 = {
|
type ApplicationAdminRow = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -17,6 +35,40 @@ type ApplicationAdminRow = {
|
|||||||
updated_at: string;
|
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 {
|
function toApplicationAdminSummary(row: ApplicationAdminRow): ApplicationAdminSummary {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -33,7 +85,7 @@ type AdminAccessAuditPayload = {
|
|||||||
actorUserId?: string | null;
|
actorUserId?: string | null;
|
||||||
actorEmail?: string | null;
|
actorEmail?: string | null;
|
||||||
route: string;
|
route: string;
|
||||||
action: string;
|
action: AdminAuditAction;
|
||||||
targetWorkspaceId?: string | null;
|
targetWorkspaceId?: string | null;
|
||||||
metadataJson?: Record<string, unknown>;
|
metadataJson?: Record<string, unknown>;
|
||||||
occurredAt?: string;
|
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;
|
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 = {
|
type UsagePeriodRow = {
|
||||||
id: string;
|
id: string;
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
@@ -174,6 +185,23 @@ type AddonPurchaseRow = {
|
|||||||
updated_at: string;
|
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> {
|
export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: string): Promise<BillingAccountRecord | null> {
|
||||||
const result = await db.query<BillingAccountRow>(
|
const result = await db.query<BillingAccountRow>(
|
||||||
`
|
`
|
||||||
@@ -509,6 +537,118 @@ export async function listRecentBillingTimelineEventsForWorkspace(db: DbClient,
|
|||||||
return result.rows.map(mapBillingTimelineEventRow);
|
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) {
|
export async function listBillingAdminWorkspaceSummaries(db: DbClient, search: string | null, limit = 50) {
|
||||||
const result = await db.query<BillingAdminWorkspaceSummaryRow>(
|
const result = await db.query<BillingAdminWorkspaceSummaryRow>(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -68,11 +68,24 @@ function parseAdminEmailAllowlist(allowlist: string | undefined): string[] {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasEntries(raw: string | undefined) {
|
||||||
|
return parseAdminEmailAllowlist(raw).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAdminEmailAllowlist() {
|
export function getAdminEmailAllowlist() {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
return parseAdminEmailAllowlist(env.ADMIN_EMAILS || env.BILLING_ADMIN_EMAILS);
|
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) {
|
export function isBillingAdminEmail(email: string) {
|
||||||
const allowlist = getAdminEmailAllowlist();
|
const allowlist = getAdminEmailAllowlist();
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ type BillingWebhookEventRow = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BillingFailedWebhookEventRow = BillingWebhookEventRow & {
|
||||||
|
workspace_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BillingFailedWebhookEventSummary = BillingWebhookEventRecord & {
|
||||||
|
workspaceName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export async function recordIncomingWebhookEvent(
|
export async function recordIncomingWebhookEvent(
|
||||||
db: DbClient,
|
db: DbClient,
|
||||||
input: {
|
input: {
|
||||||
@@ -146,6 +154,62 @@ export async function listRecentWebhookEventsForWorkspace(db: DbClient, workspac
|
|||||||
return result.rows.map(mapBillingWebhookEventRow);
|
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 {
|
function mapBillingWebhookEventRow(row: BillingWebhookEventRow): BillingWebhookEventRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { getAddonByCode } from '../../../shared/billing/addons.js';
|
|||||||
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
|
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
|
||||||
import { getPlanByCode } from '../../../shared/billing/plans.js';
|
import { getPlanByCode } from '../../../shared/billing/plans.js';
|
||||||
import { ensureWorkspaceForUser } from '../account/repository.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 { getEnv } from '../config/env.js';
|
||||||
import {
|
import {
|
||||||
assertAddonSupportsStripeCheckout,
|
assertAddonSupportsStripeCheckout,
|
||||||
@@ -336,6 +343,28 @@ export async function retrieveStripeSubscription(subscriptionId: string) {
|
|||||||
return stripe.subscriptions.retrieve(subscriptionId);
|
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' {
|
export function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): 'active' | 'inactive' | 'past_due' | 'canceled' {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'active':
|
case 'active':
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
AdminApplicationAdminUpsertRequest,
|
AdminApplicationAdminUpsertRequest,
|
||||||
} from '../../../shared/types.js';
|
} from '../../../shared/types.js';
|
||||||
import {
|
import {
|
||||||
|
ADMIN_AUDIT_ACTIONS,
|
||||||
getActiveApplicationAdminCountExcludingId,
|
getActiveApplicationAdminCountExcludingId,
|
||||||
listApplicationAdmins,
|
listApplicationAdmins,
|
||||||
recordAdminAccessAudit,
|
recordAdminAccessAudit,
|
||||||
@@ -37,7 +38,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
actorUserId: request.authUser?.id,
|
actorUserId: request.authUser?.id,
|
||||||
actorEmail: request.authUser?.email,
|
actorEmail: request.authUser?.email,
|
||||||
route: request.routeOptions.url ?? request.url,
|
route: request.routeOptions.url ?? request.url,
|
||||||
action: 'admin_access_list',
|
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_LIST,
|
||||||
});
|
});
|
||||||
const admins = await listApplicationAdmins(db);
|
const admins = await listApplicationAdmins(db);
|
||||||
const response: AdminApplicationAdminsListResponse = { admins };
|
const response: AdminApplicationAdminsListResponse = { admins };
|
||||||
@@ -60,7 +61,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
actorUserId: request.authUser?.id,
|
actorUserId: request.authUser?.id,
|
||||||
actorEmail: request.authUser?.email,
|
actorEmail: request.authUser?.email,
|
||||||
route: request.routeOptions.url ?? request.url,
|
route: request.routeOptions.url ?? request.url,
|
||||||
action: 'admin_access_upsert',
|
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_UPSERT,
|
||||||
metadataJson: {
|
metadataJson: {
|
||||||
targetEmail: payload.email.trim(),
|
targetEmail: payload.email.trim(),
|
||||||
},
|
},
|
||||||
@@ -103,7 +104,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
actorUserId: request.authUser?.id,
|
actorUserId: request.authUser?.id,
|
||||||
actorEmail: request.authUser?.email,
|
actorEmail: request.authUser?.email,
|
||||||
route: request.routeOptions.url ?? request.url,
|
route: request.routeOptions.url ?? request.url,
|
||||||
action: 'admin_access_status_changed',
|
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_STATUS_CHANGED,
|
||||||
metadataJson: {
|
metadataJson: {
|
||||||
targetAdminId: adminId,
|
targetAdminId: adminId,
|
||||||
nextStatus: payload.status,
|
nextStatus: payload.status,
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
|
|||||||
import { ZodError, z } from 'zod';
|
import { ZodError, z } from 'zod';
|
||||||
import type { AdminBootstrapClaimResponse, AdminBootstrapStatusResponse } from '../../../shared/types.js';
|
import type { AdminBootstrapClaimResponse, AdminBootstrapStatusResponse } from '../../../shared/types.js';
|
||||||
import { createDefaultWorkspaceForUser } from '../account/repository.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 { hashPassword } from '../auth/passwords.js';
|
||||||
import { createSession, setSessionCookie } from '../auth/sessions.js';
|
import { createSession, setSessionCookie } from '../auth/sessions.js';
|
||||||
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
||||||
@@ -91,7 +97,7 @@ export const adminBootstrapRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
actorEmail: user.email,
|
actorEmail: user.email,
|
||||||
route: '/api/admin/bootstrap/claim',
|
route: '/api/admin/bootstrap/claim',
|
||||||
action: 'bootstrap_admin_claimed',
|
action: ADMIN_AUDIT_ACTIONS.BOOTSTRAP_ADMIN_CLAIMED,
|
||||||
metadataJson: {
|
metadataJson: {
|
||||||
bootstrapRequiredAtClaim: true,
|
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 { z, ZodError } from 'zod';
|
||||||
import { ensureWorkspaceForUser } from '../account/repository.js';
|
import { ensureWorkspaceForUser } from '../account/repository.js';
|
||||||
import { hydrateAuthUser, requireAuth } from '../auth/middleware.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 { getDbPool } from '../db/pool.js';
|
||||||
import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js';
|
import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js';
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
actorUserId: request.authUser?.id,
|
actorUserId: request.authUser?.id,
|
||||||
actorEmail: request.authUser?.email,
|
actorEmail: request.authUser?.email,
|
||||||
route: request.routeOptions.url ?? request.url,
|
route: request.routeOptions.url ?? request.url,
|
||||||
action: 'analytics_summary',
|
action: ADMIN_AUDIT_ACTIONS.ANALYTICS_SUMMARY,
|
||||||
metadataJson: {
|
metadataJson: {
|
||||||
days: query.days ?? 30,
|
days: query.days ?? 30,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify';
|
|||||||
import { z, ZodError } from 'zod';
|
import { z, ZodError } from 'zod';
|
||||||
import { getDbPool } from '../db/pool.js';
|
import { getDbPool } from '../db/pool.js';
|
||||||
import { requireAuth } from '../auth/middleware.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 type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
|
||||||
import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js';
|
import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js';
|
||||||
import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js';
|
import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js';
|
||||||
@@ -121,7 +121,7 @@ export const billingRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
actorUserId: request.authUser?.id,
|
actorUserId: request.authUser?.id,
|
||||||
actorEmail: request.authUser?.email,
|
actorEmail: request.authUser?.email,
|
||||||
route: request.routeOptions.url ?? request.url,
|
route: request.routeOptions.url ?? request.url,
|
||||||
action: 'billing_workspaces_list',
|
action: ADMIN_AUDIT_ACTIONS.BILLING_WORKSPACES_LIST,
|
||||||
metadataJson: {
|
metadataJson: {
|
||||||
query: query.query ?? null,
|
query: query.query ?? null,
|
||||||
limit: query.limit ?? 50,
|
limit: query.limit ?? 50,
|
||||||
@@ -151,7 +151,7 @@ export const billingRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
actorUserId: request.authUser?.id,
|
actorUserId: request.authUser?.id,
|
||||||
actorEmail: request.authUser?.email,
|
actorEmail: request.authUser?.email,
|
||||||
route: request.routeOptions.url ?? request.url,
|
route: request.routeOptions.url ?? request.url,
|
||||||
action: 'billing_workspace_detail',
|
action: ADMIN_AUDIT_ACTIONS.BILLING_WORKSPACE_DETAIL,
|
||||||
targetWorkspaceId: workspaceId,
|
targetWorkspaceId: workspaceId,
|
||||||
});
|
});
|
||||||
const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId);
|
const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId);
|
||||||
|
|||||||
+105
@@ -62,6 +62,111 @@ export interface AdminApplicationAdminResponse {
|
|||||||
admin: ApplicationAdminSummary;
|
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 WorkspaceType = 'personal' | 'company';
|
||||||
export type WorkspaceRole = 'owner' | 'member';
|
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 { 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 { formatBillingStatusLabel, formatDateLabel } from '../lib/billing-ui';
|
||||||
import {
|
import {
|
||||||
getAdminAnalyticsSummary,
|
getAdminAnalyticsSummary,
|
||||||
getAdminBillingWorkspaceDetail,
|
getAdminBillingWorkspaceDetail,
|
||||||
|
getAdminSecurityPosture,
|
||||||
|
getAdminSupportDiagnostics,
|
||||||
|
listAdminAuditLogs,
|
||||||
listAdminBillingWorkspaces,
|
listAdminBillingWorkspaces,
|
||||||
listApplicationAdmins,
|
listApplicationAdmins,
|
||||||
|
requestAdminBillingResync,
|
||||||
updateApplicationAdminStatus,
|
updateApplicationAdminStatus,
|
||||||
upsertApplicationAdmin,
|
upsertApplicationAdmin,
|
||||||
} from '../lib/admin';
|
} 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 MIN_SUMMARY_DAYS = 7;
|
||||||
const MAX_SUMMARY_DAYS = 90;
|
const MAX_SUMMARY_DAYS = 90;
|
||||||
const DEFAULT_SUMMARY_DAYS = 30;
|
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));
|
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() {
|
export function AdminPage() {
|
||||||
const [summaryDays, setSummaryDays] = useState(DEFAULT_SUMMARY_DAYS);
|
const [summaryDays, setSummaryDays] = useState(DEFAULT_SUMMARY_DAYS);
|
||||||
const [analyticsSummary, setAnalyticsSummary] = useState<AdminAnalyticsSummary | null>(null);
|
const [analyticsSummary, setAnalyticsSummary] = useState<AdminAnalyticsSummary | null>(null);
|
||||||
@@ -40,6 +88,99 @@ export function AdminPage() {
|
|||||||
const [adminEmailSubmitting, setAdminEmailSubmitting] = useState(false);
|
const [adminEmailSubmitting, setAdminEmailSubmitting] = useState(false);
|
||||||
const [statusMutationAdminId, setStatusMutationAdminId] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
@@ -52,10 +193,17 @@ export function AdminPage() {
|
|||||||
setAdminsError(null);
|
setAdminsError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [summary, workspaceResponse, adminResponse] = await Promise.all([
|
const [summary, workspaceResponse, adminResponse, securityResponse, diagnosticsResponse, auditResponse] = await Promise.all([
|
||||||
getAdminAnalyticsSummary(DEFAULT_SUMMARY_DAYS),
|
getAdminAnalyticsSummary(DEFAULT_SUMMARY_DAYS),
|
||||||
listAdminBillingWorkspaces(),
|
listAdminBillingWorkspaces(),
|
||||||
listApplicationAdmins(),
|
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) {
|
if (!isMounted) {
|
||||||
@@ -65,6 +213,11 @@ export function AdminPage() {
|
|||||||
setAnalyticsSummary(summary);
|
setAnalyticsSummary(summary);
|
||||||
setWorkspaces(workspaceResponse.workspaces);
|
setWorkspaces(workspaceResponse.workspaces);
|
||||||
setAdmins(adminResponse.admins);
|
setAdmins(adminResponse.admins);
|
||||||
|
setSecurityPosture(securityResponse);
|
||||||
|
setDiagnostics(diagnosticsResponse);
|
||||||
|
setAuditItems(auditResponse.items);
|
||||||
|
setAuditTotal(auditResponse.total);
|
||||||
|
setAuditPage(auditResponse.page);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
@@ -74,11 +227,17 @@ export function AdminPage() {
|
|||||||
setSummaryError(message);
|
setSummaryError(message);
|
||||||
setWorkspacesError(message);
|
setWorkspacesError(message);
|
||||||
setAdminsError(message);
|
setAdminsError(message);
|
||||||
|
setSecurityError(message);
|
||||||
|
setDiagnosticsError(message);
|
||||||
|
setAuditError(message);
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setSummaryLoading(false);
|
setSummaryLoading(false);
|
||||||
setWorkspacesLoading(false);
|
setWorkspacesLoading(false);
|
||||||
setAdminsLoading(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}.`);
|
setAdminMutationFeedback(`Admin access is active for ${response.admin.email}.`);
|
||||||
setAdminEmailInput('');
|
setAdminEmailInput('');
|
||||||
await refreshAdmins();
|
await refreshAdmins();
|
||||||
|
await loadSecurityPosture();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAdminMutationError(error instanceof Error ? error.message : 'Failed to add or reactivate admin.');
|
setAdminMutationError(error instanceof Error ? error.message : 'Failed to add or reactivate admin.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -177,6 +337,7 @@ export function AdminPage() {
|
|||||||
const response = await updateApplicationAdminStatus(adminId, status);
|
const response = await updateApplicationAdminStatus(adminId, status);
|
||||||
setAdminMutationFeedback(`${response.admin.email} is now ${status}.`);
|
setAdminMutationFeedback(`${response.admin.email} is now ${status}.`);
|
||||||
await refreshAdmins();
|
await refreshAdmins();
|
||||||
|
await loadSecurityPosture();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAdminMutationError(error instanceof Error ? error.message : 'Failed to update admin status.');
|
setAdminMutationError(error instanceof Error ? error.message : 'Failed to update admin status.');
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow="Admin"
|
eyebrow="Admin"
|
||||||
title="Admin Console"
|
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">
|
<Card className="p-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -263,6 +534,337 @@ export function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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">
|
<Card className="p-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center 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 }> }) {
|
function MetricBucketCard({ title, buckets }: { title: string; buckets: Array<{ key: string; count: number }> }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-stone-200 p-4">
|
<div className="rounded-2xl border border-stone-200 p-4">
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AdminAuditLogFilters,
|
||||||
|
AdminAuditLogListResponse,
|
||||||
|
AdminBillingResyncRequest,
|
||||||
|
AdminBillingResyncResponse,
|
||||||
AdminApplicationAdminResponse,
|
AdminApplicationAdminResponse,
|
||||||
AdminApplicationAdminsListResponse,
|
AdminApplicationAdminsListResponse,
|
||||||
AdminAnalyticsSummary,
|
AdminAnalyticsSummary,
|
||||||
|
AdminSecurityPostureResponse,
|
||||||
|
AdminSupportDiagnosticsResponse,
|
||||||
ApplicationAdminStatus,
|
ApplicationAdminStatus,
|
||||||
BillingAdminWorkspaceDetailResponse,
|
BillingAdminWorkspaceDetailResponse,
|
||||||
BillingAdminWorkspaceListResponse,
|
BillingAdminWorkspaceListResponse,
|
||||||
@@ -40,3 +46,68 @@ export async function updateApplicationAdminStatus(adminId: string, status: Appl
|
|||||||
body: JSON.stringify({ status }),
|
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