feat: add admin console and app-admin access management
This commit is contained in:
@@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- Added a first-run admin bootstrap flow with `/api/admin/bootstrap/status` and `/api/admin/bootstrap/claim` so the initial application-admin account can be claimed safely.
|
- Added a first-run admin bootstrap flow with `/api/admin/bootstrap/status` and `/api/admin/bootstrap/claim` so the initial application-admin account can be claimed safely.
|
||||||
- Added `bootstrap-token` and `bootstrap-enabled` environment/config support, plus setup docs and operational checklist updates for first-run admin provisioning.
|
- Added `bootstrap-token` and `bootstrap-enabled` environment/config support, plus setup docs and operational checklist updates for first-run admin provisioning.
|
||||||
- Added authenticated UI admin-badge visibility by exposing `isAdmin` on shared session/auth payloads.
|
- Added authenticated UI admin-badge visibility by exposing `isAdmin` on shared session/auth payloads.
|
||||||
|
- Added a dedicated read-only `Admin Console` page with analytics summary and billing workspace support visibility tools.
|
||||||
|
- Added app-admin access management APIs and UI for list/add/reactivate/disable actions, with last-active-admin lockout guardrails and audit events.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Replaced env-only billing-admin authorization with application-admin checks backed by database records, while keeping env allowlist fallback support for rollout safety.
|
- Replaced env-only billing-admin authorization with application-admin checks backed by database records, while keeping env allowlist fallback support for rollout safety.
|
||||||
@@ -22,6 +24,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- Updated README and TODO planning docs for phased admin-console rollout and the first-run operational checklist.
|
- Updated README and TODO planning docs for phased admin-console rollout and the first-run operational checklist.
|
||||||
- Hardened pending-downgrade lifecycle handling so Stripe-scheduled downgrades are preserved in billing state and apply automatically when the effective date is reached.
|
- Hardened pending-downgrade lifecycle handling so Stripe-scheduled downgrades are preserved in billing state and apply automatically when the effective date is reached.
|
||||||
- Clarified post-downgrade quota messaging in enforcement, account UI, and admin billing detail so over-limit behavior is explicit after scheduled plan changes.
|
- Clarified post-downgrade quota messaging in enforcement, account UI, and admin billing detail so over-limit behavior is explicit after scheduled plan changes.
|
||||||
|
- Refocused the account page on user self-service while moving admin billing tooling into `Admin Console`, with admin-only navigation.
|
||||||
|
- Updated deployment Compose wiring so services can attach to a shared external `locale-all` network.
|
||||||
|
- Marked `Admin Console` Phase A and Phase B implementation progress in the pricing TODO tracker.
|
||||||
|
|
||||||
## [2026-05-22]
|
## [2026-05-22]
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -87,23 +87,23 @@
|
|||||||
- entitlement/usage treatment when the target plan is below current usage
|
- entitlement/usage treatment when the target plan is below current usage
|
||||||
- account messaging for pending downgrade state
|
- account messaging for pending downgrade state
|
||||||
|
|
||||||
### Admin Dashboard / Console Incremental Plan
|
## 12) Admin Dashboard / Console Incremental Plan
|
||||||
- [ ] Phase A (Read-only Admin Console foundation): Create dedicated admin page(s) separate from Account page and gate all access to app-admin users only.
|
- [x] Phase A (Read-only Admin Console foundation): Create dedicated admin page(s) separate from Account page and gate all access to app-admin users only.
|
||||||
- [ ] Phase A (Read-only Admin Console foundation): Add admin navigation entry/tab visible only to app-admin users.
|
- [x] Phase A (Read-only Admin Console foundation): Add admin navigation entry/tab visible only to app-admin users.
|
||||||
- [ ] Phase A (Read-only Admin Console foundation): Move existing admin billing visibility tools (workspace search + workspace detail) from Account page to admin page.
|
- [x] Phase A (Read-only Admin Console foundation): Move existing admin billing visibility tools (workspace search + workspace detail) from Account page to admin page.
|
||||||
- [ ] Phase A (Read-only Admin Console foundation): Add admin analytics summary panel on admin page powered by `/admin/analytics/summary`.
|
- [x] Phase A (Read-only Admin Console foundation): Add admin analytics summary panel on admin page powered by `/admin/analytics/summary`.
|
||||||
- [ ] Phase A (Read-only Admin Console foundation): Keep server-side `requireAdmin` as the source of truth (UI checks are convenience only).
|
- [x] Phase A (Read-only Admin Console foundation): Keep server-side `requireAdmin` as the source of truth (UI checks are convenience only).
|
||||||
- [ ] Phase B (Admin Access Management): Add admin-only APIs for listing, adding, disabling, and re-enabling application admins.
|
- [x] Phase B (Admin Access Management): Add admin-only APIs for listing, adding, disabling, and re-enabling application admins.
|
||||||
- [ ] 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).
|
||||||
- [ ] 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).
|
||||||
- [ ] 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).
|
- [ ] Phase C (Audit & Support Operations): Add admin audit log page/table with filters (actor, action, workspace, date window).
|
||||||
- [ ] Phase C (Audit & Support Operations): Expose bootstrap/security posture checks in admin UI (bootstrap enabled state, fallback allowlist usage warnings).
|
- [ ] Phase C (Audit & Support Operations): Expose bootstrap/security posture checks in admin UI (bootstrap enabled state, fallback allowlist usage warnings).
|
||||||
- [ ] Phase C (Audit & Support Operations): Add support-oriented diagnostics widgets (recent webhook issues, billing sync errors, timeline anomalies).
|
- [ ] Phase C (Audit & Support Operations): Add support-oriented diagnostics widgets (recent webhook issues, billing sync errors, timeline anomalies).
|
||||||
- [ ] 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.
|
||||||
- [ ] 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.
|
||||||
|
|
||||||
## 12) [DEFER] Operational Enforcement Follow-Up
|
## 13) [DEFER] Operational Enforcement Follow-Up
|
||||||
- [ ] Add queue prioritization by plan tier.
|
- [ ] Add queue prioritization by plan tier.
|
||||||
- [ ] Add throttling/fair-usage controls.
|
- [ ] Add throttling/fair-usage controls.
|
||||||
- [ ] Add export-route enforcement once CSV/export generation moves to a backend endpoint.
|
- [ ] Add export-route enforcement once CSV/export generation moves to a backend endpoint.
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
- [ ] Future note: export enforcement remains deferred until CSV/export generation moves to a backend endpoint.
|
- [ ] Future note: export enforcement remains deferred until CSV/export generation moves to a backend endpoint.
|
||||||
- [ ] Future note: enrichment-route enforcement remains deferred until enrichment actions/routes are implemented.
|
- [ ] Future note: enrichment-route enforcement remains deferred until enrichment actions/routes are implemented.
|
||||||
|
|
||||||
## 13) [DEFER] Founder / LTD Strategy
|
## 14) [DEFER] Founder / LTD Strategy
|
||||||
- [ ] Decide whether to launch founder LTD at all.
|
- [ ] Decide whether to launch founder LTD at all.
|
||||||
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
|
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
|
||||||
- [ ] Define founder SKUs:
|
- [ ] Define founder SKUs:
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
|
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
|
||||||
- [ ] Define which future features are excluded from LTD plans.
|
- [ ] Define which future features are excluded from LTD plans.
|
||||||
|
|
||||||
## 14) Rollout Plan
|
## 15) Rollout Plan
|
||||||
- [ ] Phase 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model.
|
- [ ] Phase 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model.
|
||||||
- [ ] Phase 2: implement usage ledger and backend enforcement.
|
- [ ] Phase 2: implement usage ledger and backend enforcement.
|
||||||
- [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises.
|
- [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgis/postgis:16-3.4
|
image: postgis/postgis:16-3.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- locale-all
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
@@ -49,6 +51,8 @@ services:
|
|||||||
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
|
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
|
||||||
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS}
|
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- locale-all
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT}:${APP_PORT}"
|
- "${APP_PORT}:${APP_PORT}"
|
||||||
|
|
||||||
@@ -69,6 +73,8 @@ services:
|
|||||||
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
|
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
|
||||||
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS}
|
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- locale-all
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -81,8 +87,14 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- locale-all
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT}:80"
|
- "${WEB_PORT}:80"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
leads4less-db:
|
leads4less-db:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
locale-all:
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { healthRoutes } from './routes/health.js';
|
|||||||
import { searchJobRoutes } from './routes/search-jobs.js';
|
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';
|
||||||
|
|
||||||
function parseAllowedOrigins(rawOrigins: string) {
|
function parseAllowedOrigins(rawOrigins: string) {
|
||||||
return rawOrigins
|
return rawOrigins
|
||||||
@@ -58,6 +59,7 @@ export async function buildApp() {
|
|||||||
await app.register(deepResearchRoutes, { prefix: '/api' });
|
await app.register(deepResearchRoutes, { prefix: '/api' });
|
||||||
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' });
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import type { Pool, PoolClient } from 'pg';
|
import type { Pool, PoolClient } from 'pg';
|
||||||
|
import type { ApplicationAdminSummary, ApplicationAdminStatus } from '../../../shared/types.js';
|
||||||
import { getAdminEmailAllowlist } from '../config/env.js';
|
import { getAdminEmailAllowlist } from '../config/env.js';
|
||||||
import { getDbPool } from '../db/pool.js';
|
import { getDbPool } from '../db/pool.js';
|
||||||
|
|
||||||
@@ -16,6 +17,18 @@ type ApplicationAdminRow = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toApplicationAdminSummary(row: ApplicationAdminRow): ApplicationAdminSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
emailNormalized: row.email_normalized,
|
||||||
|
status: row.status,
|
||||||
|
createdByUserId: row.created_by_user_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type AdminAccessAuditPayload = {
|
type AdminAccessAuditPayload = {
|
||||||
actorUserId?: string | null;
|
actorUserId?: string | null;
|
||||||
actorEmail?: string | null;
|
actorEmail?: string | null;
|
||||||
@@ -48,6 +61,76 @@ export async function getApplicationAdminByEmail(db: DbClient, email: string) {
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listApplicationAdmins(db: DbClient): Promise<ApplicationAdminSummary[]> {
|
||||||
|
const result = await db.query<ApplicationAdminRow>(
|
||||||
|
`
|
||||||
|
select id, email, email_normalized, status, permissions_json, created_by_user_id, created_at, updated_at
|
||||||
|
from public.application_admins
|
||||||
|
order by
|
||||||
|
case when status = 'active' then 0 else 1 end asc,
|
||||||
|
email_normalized asc
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map(toApplicationAdminSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertApplicationAdminByEmail(
|
||||||
|
db: DbClient,
|
||||||
|
input: { email: string; actorUserId?: string | null },
|
||||||
|
): Promise<ApplicationAdminSummary> {
|
||||||
|
const normalizedEmail = normalizeEmail(input.email);
|
||||||
|
const result = await db.query<ApplicationAdminRow>(
|
||||||
|
`
|
||||||
|
insert into public.application_admins (email, email_normalized, status, permissions_json, created_by_user_id)
|
||||||
|
values ($1, $2, 'active', '[]'::jsonb, $3)
|
||||||
|
on conflict (email_normalized)
|
||||||
|
do update set
|
||||||
|
email = excluded.email,
|
||||||
|
status = 'active',
|
||||||
|
updated_at = now()
|
||||||
|
returning id, email, email_normalized, status, permissions_json, created_by_user_id, created_at, updated_at
|
||||||
|
`,
|
||||||
|
[input.email.trim(), normalizedEmail, input.actorUserId ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
return toApplicationAdminSummary(result.rows[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateApplicationAdminStatusById(
|
||||||
|
db: DbClient,
|
||||||
|
input: { adminId: string; status: ApplicationAdminStatus },
|
||||||
|
): Promise<ApplicationAdminSummary | null> {
|
||||||
|
const result = await db.query<ApplicationAdminRow>(
|
||||||
|
`
|
||||||
|
update public.application_admins
|
||||||
|
set status = $2, updated_at = now()
|
||||||
|
where id = $1
|
||||||
|
returning id, email, email_normalized, status, permissions_json, created_by_user_id, created_at, updated_at
|
||||||
|
`,
|
||||||
|
[input.adminId, input.status],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.rows[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toApplicationAdminSummary(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveApplicationAdminCountExcludingId(db: DbClient, adminId: string): Promise<number> {
|
||||||
|
const result = await db.query<{ count: string }>(
|
||||||
|
`
|
||||||
|
select count(*)::text as count
|
||||||
|
from public.application_admins
|
||||||
|
where status = 'active' and id <> $1
|
||||||
|
`,
|
||||||
|
[adminId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Number(result.rows[0]?.count ?? '0');
|
||||||
|
}
|
||||||
|
|
||||||
export async function isApplicationAdmin(db: DbClient, email: string) {
|
export async function isApplicationAdmin(db: DbClient, email: string) {
|
||||||
const admin = await getApplicationAdminByEmail(db, email);
|
const admin = await getApplicationAdminByEmail(db, email);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { ZodError, z } from 'zod';
|
||||||
|
import type {
|
||||||
|
AdminApplicationAdminResponse,
|
||||||
|
AdminApplicationAdminsListResponse,
|
||||||
|
AdminApplicationAdminStatusUpdateRequest,
|
||||||
|
AdminApplicationAdminUpsertRequest,
|
||||||
|
} from '../../../shared/types.js';
|
||||||
|
import {
|
||||||
|
getActiveApplicationAdminCountExcludingId,
|
||||||
|
listApplicationAdmins,
|
||||||
|
recordAdminAccessAudit,
|
||||||
|
requireAdmin,
|
||||||
|
updateApplicationAdminStatusById,
|
||||||
|
upsertApplicationAdminByEmail,
|
||||||
|
} from '../auth/admin.js';
|
||||||
|
import { requireAuth } from '../auth/middleware.js';
|
||||||
|
import { getDbPool } from '../db/pool.js';
|
||||||
|
|
||||||
|
const upsertAdminSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusUpdateSchema = z.object({
|
||||||
|
status: z.enum(['active', 'disabled']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminIdParamsSchema = z.object({
|
||||||
|
adminId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
app.get('/admin/access/admins', { 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_access_list',
|
||||||
|
});
|
||||||
|
const admins = await listApplicationAdmins(db);
|
||||||
|
const response: AdminApplicationAdminsListResponse = { admins };
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error(error);
|
||||||
|
return reply.code(500).send({ error: 'Failed to list application admins.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/access/admins', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const payload = upsertAdminSchema.parse(request.body) as AdminApplicationAdminUpsertRequest;
|
||||||
|
const db = getDbPool();
|
||||||
|
const admin = await upsertApplicationAdminByEmail(db, {
|
||||||
|
email: payload.email,
|
||||||
|
actorUserId: request.authUser?.id,
|
||||||
|
});
|
||||||
|
await recordAdminAccessAudit(db, {
|
||||||
|
actorUserId: request.authUser?.id,
|
||||||
|
actorEmail: request.authUser?.email,
|
||||||
|
route: request.routeOptions.url ?? request.url,
|
||||||
|
action: 'admin_access_upsert',
|
||||||
|
metadataJson: {
|
||||||
|
targetEmail: payload.email.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response: AdminApplicationAdminResponse = { admin };
|
||||||
|
return reply.code(201).send(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin upsert payload.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error(error);
|
||||||
|
return reply.code(500).send({ error: 'Failed to upsert application admin.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/admin/access/admins/:adminId', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { adminId } = adminIdParamsSchema.parse(request.params);
|
||||||
|
const payload = statusUpdateSchema.parse(request.body) as AdminApplicationAdminStatusUpdateRequest;
|
||||||
|
const db = getDbPool();
|
||||||
|
|
||||||
|
if (payload.status === 'disabled') {
|
||||||
|
const remainingActiveAdminCount = await getActiveApplicationAdminCountExcludingId(db, adminId);
|
||||||
|
if (remainingActiveAdminCount < 1) {
|
||||||
|
return reply.code(409).send({ error: 'Cannot disable the last active application admin.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await updateApplicationAdminStatusById(db, {
|
||||||
|
adminId,
|
||||||
|
status: payload.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
return reply.code(404).send({ error: 'Application admin not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAdminAccessAudit(db, {
|
||||||
|
actorUserId: request.authUser?.id,
|
||||||
|
actorEmail: request.authUser?.email,
|
||||||
|
route: request.routeOptions.url ?? request.url,
|
||||||
|
action: 'admin_access_status_changed',
|
||||||
|
metadataJson: {
|
||||||
|
targetAdminId: adminId,
|
||||||
|
nextStatus: payload.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: AdminApplicationAdminResponse = { admin };
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin status update payload.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error(error);
|
||||||
|
return reply.code(500).send({ error: 'Failed to update application admin status.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -34,6 +34,34 @@ export interface AdminBootstrapClaimResponse {
|
|||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApplicationAdminStatus = 'active' | 'disabled';
|
||||||
|
|
||||||
|
export interface ApplicationAdminSummary {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
emailNormalized: string;
|
||||||
|
status: ApplicationAdminStatus;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminApplicationAdminsListResponse {
|
||||||
|
admins: ApplicationAdminSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminApplicationAdminUpsertRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminApplicationAdminStatusUpdateRequest {
|
||||||
|
status: ApplicationAdminStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminApplicationAdminResponse {
|
||||||
|
admin: ApplicationAdminSummary;
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceType = 'personal' | 'company';
|
export type WorkspaceType = 'personal' | 'company';
|
||||||
export type WorkspaceRole = 'owner' | 'member';
|
export type WorkspaceRole = 'owner' | 'member';
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Layout, type AppTab } from './components/Layout';
|
import { Layout, type AppTab } from './components/Layout';
|
||||||
import { AccountPage } from './components/AccountPage';
|
import { AccountPage } from './components/AccountPage';
|
||||||
|
import { AdminPage } from './components/AdminPage';
|
||||||
import { Dashboard } from './components/Dashboard';
|
import { Dashboard } from './components/Dashboard';
|
||||||
import { MapView } from './components/MapView';
|
import { MapView } from './components/MapView';
|
||||||
import { PricingCards } from './components/PricingCards';
|
import { PricingCards } from './components/PricingCards';
|
||||||
@@ -108,6 +109,12 @@ export default function App() {
|
|||||||
|
|
||||||
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
|
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'admin' && !user?.isAdmin) {
|
||||||
|
setActiveTab('account');
|
||||||
|
}
|
||||||
|
}, [activeTab, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
@@ -365,6 +372,7 @@ export default function App() {
|
|||||||
onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)}
|
onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'admin' && user.isAdmin && <AdminPage />}
|
||||||
</Layout>
|
</Layout>
|
||||||
</APIProvider>
|
</APIProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import { useEffect, useState } from 'react';
|
|||||||
import { getEligibleAddonsForPlan } from '../../shared/billing/addons';
|
import { getEligibleAddonsForPlan } from '../../shared/billing/addons';
|
||||||
import { formatLifecycleDate } from '../../shared/billing/lifecycle';
|
import { formatLifecycleDate } from '../../shared/billing/lifecycle';
|
||||||
import { getPlanByCode, getPublicPricingPlans, type PlanCode } from '../../shared/billing/plans';
|
import { getPlanByCode, getPublicPricingPlans, type PlanCode } from '../../shared/billing/plans';
|
||||||
import type { AccountPageData, AppUser, BillingAdminWorkspaceDetail, BillingAdminWorkspaceSummary } from '../../shared/types';
|
import type { AccountPageData, AppUser } from '../../shared/types';
|
||||||
import {
|
import {
|
||||||
createAddonCheckout,
|
createAddonCheckout,
|
||||||
createBillingPortalSession,
|
createBillingPortalSession,
|
||||||
createSubscriptionCheckout,
|
createSubscriptionCheckout,
|
||||||
getAccountPageData,
|
getAccountPageData,
|
||||||
getAdminBillingWorkspaceDetail,
|
|
||||||
listAdminBillingWorkspaces,
|
|
||||||
updateAccountProfile,
|
updateAccountProfile,
|
||||||
} from '../lib/account';
|
} from '../lib/account';
|
||||||
import {
|
import {
|
||||||
@@ -50,11 +48,6 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
|||||||
const [billingAction, setBillingAction] = useState<string | null>(null);
|
const [billingAction, setBillingAction] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [adminQuery, setAdminQuery] = useState('');
|
|
||||||
const [adminWorkspaces, setAdminWorkspaces] = useState<BillingAdminWorkspaceSummary[]>([]);
|
|
||||||
const [adminWorkspaceDetail, setAdminWorkspaceDetail] = useState<BillingAdminWorkspaceDetail | null>(null);
|
|
||||||
const [adminLoading, setAdminLoading] = useState(false);
|
|
||||||
const isAdmin = account?.isAdmin ?? account?.isBillingAdmin ?? false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -75,15 +68,6 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
|||||||
setAvatarUrl(nextAccount.profile.avatarUrl ?? '');
|
setAvatarUrl(nextAccount.profile.avatarUrl ?? '');
|
||||||
setWorkspaceName(nextAccount.workspace.name);
|
setWorkspaceName(nextAccount.workspace.name);
|
||||||
setNotice(getBillingReturnNotice());
|
setNotice(getBillingReturnNotice());
|
||||||
|
|
||||||
if ((nextAccount.isAdmin ?? nextAccount.isBillingAdmin ?? false)) {
|
|
||||||
setAdminLoading(true);
|
|
||||||
const adminResponse = await listAdminBillingWorkspaces();
|
|
||||||
if (isMounted) {
|
|
||||||
setAdminWorkspaces(adminResponse.workspaces);
|
|
||||||
}
|
|
||||||
setAdminLoading(false);
|
|
||||||
}
|
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
@@ -236,34 +220,6 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
|||||||
await handleSubscriptionCheckout(planCode);
|
await handleSubscriptionCheckout(planCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadAdminWorkspace = async (workspaceId: string) => {
|
|
||||||
setAdminLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await getAdminBillingWorkspaceDetail(workspaceId);
|
|
||||||
setAdminWorkspaceDetail(response.workspace);
|
|
||||||
} catch (nextError) {
|
|
||||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load billing workspace detail.');
|
|
||||||
} finally {
|
|
||||||
setAdminLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchAdminWorkspaces = async () => {
|
|
||||||
setAdminLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await listAdminBillingWorkspaces(adminQuery);
|
|
||||||
setAdminWorkspaces(response.workspaces);
|
|
||||||
} catch (nextError) {
|
|
||||||
setError(nextError instanceof Error ? nextError.message : 'Failed to search billing workspaces.');
|
|
||||||
} finally {
|
|
||||||
setAdminLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -634,73 +590,6 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
|||||||
<p className="mt-4 text-sm text-stone-600">{account.team.message}</p>
|
<p className="mt-4 text-sm text-stone-600">{account.team.message}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isAdmin ? (
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Admin Billing</p>
|
|
||||||
<h3 className="text-lg font-semibold text-stone-950">Support visibility</h3>
|
|
||||||
</div>
|
|
||||||
{adminLoading ? <Loader2 className="h-4 w-4 animate-spin text-stone-500" /> : null}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex gap-3">
|
|
||||||
<Input value={adminQuery} onChange={(event) => setAdminQuery(event.target.value)} placeholder="Search workspace or Stripe id" />
|
|
||||||
<Button type="button" variant="secondary" onClick={() => void handleSearchAdminWorkspaces()} disabled={adminLoading}>
|
|
||||||
Search
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{adminWorkspaces.map((workspace) => (
|
|
||||||
<button
|
|
||||||
key={workspace.workspaceId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleLoadAdminWorkspace(workspace.workspaceId)}
|
|
||||||
className="w-full rounded-2xl border border-stone-200 p-4 text-left transition hover:border-stone-300 hover:bg-stone-50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-stone-900">{workspace.workspaceName}</p>
|
|
||||||
<p className="text-xs text-stone-500">{workspace.planCode || 'No plan'} · {workspace.status}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant={workspace.billingSyncStatus === 'error' ? 'danger' : workspace.billingSyncStatus === 'stale' ? 'warning' : 'success'}>
|
|
||||||
{workspace.billingSyncStatus}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{adminWorkspaceDetail ? (
|
|
||||||
<div className="mt-6 space-y-4 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-stone-900">{adminWorkspaceDetail.summary.workspaceName}</p>
|
|
||||||
<p className="text-stone-600">{adminWorkspaceDetail.summary.planCode || 'No plan'} · {adminWorkspaceDetail.summary.status}</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 text-stone-600">
|
|
||||||
<div>Renewal: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.currentPeriodEndsAt)}</span></div>
|
|
||||||
<div>Grace ends: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.gracePeriodEndsAt)}</span></div>
|
|
||||||
<div>Pending plan: <span className="font-medium text-stone-900">{adminWorkspaceDetail.billing.pendingPlanCode || 'None'}</span></div>
|
|
||||||
<div>Pending effective: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.pendingPlanEffectiveAt)}</span></div>
|
|
||||||
<div>Subscription ref: <span className="font-medium text-stone-900">{adminWorkspaceDetail.summary.externalSubscriptionRef || 'Not set'}</span></div>
|
|
||||||
<div>Usage period id: <span className="font-medium text-stone-900">{adminWorkspaceDetail.usagePeriodId || 'Not active'}</span></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-stone-900">Recent timeline</p>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{adminWorkspaceDetail.timeline.map((entry) => (
|
|
||||||
<div key={entry.id} className="rounded-xl border border-stone-200 bg-white px-3 py-2">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="font-medium text-stone-900">{entry.eventType}</span>
|
|
||||||
<span className="text-xs text-stone-500">{formatDateLabel(entry.occurredAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { BarChart3, Loader2, Search, ShieldCheck } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { AdminAnalyticsSummary, ApplicationAdminSummary, BillingAdminWorkspaceDetail, BillingAdminWorkspaceSummary } from '../../shared/types';
|
||||||
|
import { formatBillingStatusLabel, formatDateLabel } from '../lib/billing-ui';
|
||||||
|
import {
|
||||||
|
getAdminAnalyticsSummary,
|
||||||
|
getAdminBillingWorkspaceDetail,
|
||||||
|
listAdminBillingWorkspaces,
|
||||||
|
listApplicationAdmins,
|
||||||
|
updateApplicationAdminStatus,
|
||||||
|
upsertApplicationAdmin,
|
||||||
|
} from '../lib/admin';
|
||||||
|
import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader } from './ui';
|
||||||
|
|
||||||
|
const MIN_SUMMARY_DAYS = 7;
|
||||||
|
const MAX_SUMMARY_DAYS = 90;
|
||||||
|
const DEFAULT_SUMMARY_DAYS = 30;
|
||||||
|
|
||||||
|
const clampSummaryDays = (value: number) => Math.min(MAX_SUMMARY_DAYS, Math.max(MIN_SUMMARY_DAYS, value));
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const [summaryDays, setSummaryDays] = useState(DEFAULT_SUMMARY_DAYS);
|
||||||
|
const [analyticsSummary, setAnalyticsSummary] = useState<AdminAnalyticsSummary | null>(null);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||||
|
const [summaryError, setSummaryError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [workspaces, setWorkspaces] = useState<BillingAdminWorkspaceSummary[]>([]);
|
||||||
|
const [selectedWorkspace, setSelectedWorkspace] = useState<BillingAdminWorkspaceDetail | null>(null);
|
||||||
|
const [workspacesLoading, setWorkspacesLoading] = useState(true);
|
||||||
|
const [workspacesError, setWorkspacesError] = useState<string | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const [admins, setAdmins] = useState<ApplicationAdminSummary[]>([]);
|
||||||
|
const [adminsLoading, setAdminsLoading] = useState(true);
|
||||||
|
const [adminsError, setAdminsError] = useState<string | null>(null);
|
||||||
|
const [adminMutationFeedback, setAdminMutationFeedback] = useState<string | null>(null);
|
||||||
|
const [adminMutationError, setAdminMutationError] = useState<string | null>(null);
|
||||||
|
const [adminEmailInput, setAdminEmailInput] = useState('');
|
||||||
|
const [adminEmailSubmitting, setAdminEmailSubmitting] = useState(false);
|
||||||
|
const [statusMutationAdminId, setStatusMutationAdminId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadAdminData = async () => {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
setWorkspacesLoading(true);
|
||||||
|
setAdminsLoading(true);
|
||||||
|
setSummaryError(null);
|
||||||
|
setWorkspacesError(null);
|
||||||
|
setAdminsError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [summary, workspaceResponse, adminResponse] = await Promise.all([
|
||||||
|
getAdminAnalyticsSummary(DEFAULT_SUMMARY_DAYS),
|
||||||
|
listAdminBillingWorkspaces(),
|
||||||
|
listApplicationAdmins(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnalyticsSummary(summary);
|
||||||
|
setWorkspaces(workspaceResponse.workspaces);
|
||||||
|
setAdmins(adminResponse.admins);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load admin console data.';
|
||||||
|
setSummaryError(message);
|
||||||
|
setWorkspacesError(message);
|
||||||
|
setAdminsError(message);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
setWorkspacesLoading(false);
|
||||||
|
setAdminsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadAdminData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefreshSummary = async () => {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
setSummaryError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clampedDays = clampSummaryDays(summaryDays);
|
||||||
|
if (clampedDays !== summaryDays) {
|
||||||
|
setSummaryDays(clampedDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await getAdminAnalyticsSummary(clampedDays);
|
||||||
|
setAnalyticsSummary(summary);
|
||||||
|
} catch (error) {
|
||||||
|
setSummaryError(error instanceof Error ? error.message : 'Failed to refresh analytics summary.');
|
||||||
|
} finally {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchWorkspaces = async () => {
|
||||||
|
setWorkspacesLoading(true);
|
||||||
|
setWorkspacesError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await listAdminBillingWorkspaces(searchQuery);
|
||||||
|
setWorkspaces(response.workspaces);
|
||||||
|
setSelectedWorkspace(null);
|
||||||
|
} catch (error) {
|
||||||
|
setWorkspacesError(error instanceof Error ? error.message : 'Failed to search billing workspaces.');
|
||||||
|
} finally {
|
||||||
|
setWorkspacesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadWorkspaceDetail = async (workspaceId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
setWorkspacesError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getAdminBillingWorkspaceDetail(workspaceId);
|
||||||
|
setSelectedWorkspace(response.workspace);
|
||||||
|
} catch (error) {
|
||||||
|
setWorkspacesError(error instanceof Error ? error.message : 'Failed to load workspace detail.');
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAdmins = async () => {
|
||||||
|
setAdminsLoading(true);
|
||||||
|
setAdminsError(null);
|
||||||
|
try {
|
||||||
|
const response = await listApplicationAdmins();
|
||||||
|
setAdmins(response.admins);
|
||||||
|
} catch (error) {
|
||||||
|
setAdminsError(error instanceof Error ? error.message : 'Failed to load application admins.');
|
||||||
|
} finally {
|
||||||
|
setAdminsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpsertAdmin = async () => {
|
||||||
|
setAdminMutationFeedback(null);
|
||||||
|
setAdminMutationError(null);
|
||||||
|
setAdminEmailSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await upsertApplicationAdmin(adminEmailInput);
|
||||||
|
setAdminMutationFeedback(`Admin access is active for ${response.admin.email}.`);
|
||||||
|
setAdminEmailInput('');
|
||||||
|
await refreshAdmins();
|
||||||
|
} catch (error) {
|
||||||
|
setAdminMutationError(error instanceof Error ? error.message : 'Failed to add or reactivate admin.');
|
||||||
|
} finally {
|
||||||
|
setAdminEmailSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetAdminStatus = async (adminId: string, status: 'active' | 'disabled') => {
|
||||||
|
setAdminMutationFeedback(null);
|
||||||
|
setAdminMutationError(null);
|
||||||
|
setStatusMutationAdminId(adminId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await updateApplicationAdminStatus(adminId, status);
|
||||||
|
setAdminMutationFeedback(`${response.admin.email} is now ${status}.`);
|
||||||
|
await refreshAdmins();
|
||||||
|
} catch (error) {
|
||||||
|
setAdminMutationError(error instanceof Error ? error.message : 'Failed to update admin status.');
|
||||||
|
} finally {
|
||||||
|
setStatusMutationAdminId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageContainer>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Admin"
|
||||||
|
title="Admin Console"
|
||||||
|
description="Visibility into pricing analytics, admin access identities, 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">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Admin Access Management</p>
|
||||||
|
<h2 className="text-lg font-semibold text-stone-950">Application admin identities</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-end gap-3">
|
||||||
|
<div className="min-w-[260px] flex-1">
|
||||||
|
<FieldLabel>Admin email</FieldLabel>
|
||||||
|
<Input
|
||||||
|
value={adminEmailInput}
|
||||||
|
onChange={(event) => setAdminEmailInput(event.target.value)}
|
||||||
|
placeholder="name@company.com"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => void handleUpsertAdmin()} disabled={adminEmailSubmitting}>
|
||||||
|
{adminEmailSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Add or reactivate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adminsError ? <Alert className="mt-4" variant="error">{adminsError}</Alert> : null}
|
||||||
|
{adminMutationError ? <Alert className="mt-4" variant="error">{adminMutationError}</Alert> : null}
|
||||||
|
{adminMutationFeedback ? <Alert className="mt-4" variant="success">{adminMutationFeedback}</Alert> : null}
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{adminsLoading && admins.length === 0 ? <LoadingState message="Loading admin identities..." /> : null}
|
||||||
|
{admins.map((admin) => {
|
||||||
|
const isMutatingStatus = statusMutationAdminId === admin.id;
|
||||||
|
const isActive = admin.status === 'active';
|
||||||
|
return (
|
||||||
|
<div key={admin.id} className="rounded-2xl border border-stone-200 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-900">{admin.email}</p>
|
||||||
|
<p className="text-xs text-stone-500">Created {formatDateLabel(admin.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={isActive ? 'success' : 'warning'}>{admin.status}</Badge>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={isActive ? 'subtle' : 'secondary'}
|
||||||
|
onClick={() => void handleSetAdminStatus(admin.id, isActive ? 'disabled' : 'active')}
|
||||||
|
disabled={isMutatingStatus}
|
||||||
|
>
|
||||||
|
{isMutatingStatus ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
{isActive ? 'Disable' : 'Re-enable'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!adminsLoading && admins.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 application admins are configured.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Analytics Summary</p>
|
||||||
|
<h2 className="text-lg font-semibold text-stone-950">Admin metrics overview</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Days</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={MIN_SUMMARY_DAYS}
|
||||||
|
max={MAX_SUMMARY_DAYS}
|
||||||
|
value={summaryDays}
|
||||||
|
onChange={(event) => {
|
||||||
|
const numericValue = Number(event.target.value);
|
||||||
|
const normalizedValue = Number.isFinite(numericValue) ? numericValue : DEFAULT_SUMMARY_DAYS;
|
||||||
|
setSummaryDays(clampSummaryDays(normalizedValue));
|
||||||
|
}}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => void handleRefreshSummary()} disabled={summaryLoading}>
|
||||||
|
{summaryLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{summaryError ? <Alert variant="error">{summaryError}</Alert> : null}
|
||||||
|
{summaryLoading && !analyticsSummary ? <LoadingState message="Loading analytics summary..." /> : null}
|
||||||
|
{analyticsSummary ? (
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
<MetricBucketCard title="Plan mix" buckets={analyticsSummary.planMix} />
|
||||||
|
<MetricBucketCard title="Churn signals" buckets={analyticsSummary.churnSignals} />
|
||||||
|
<MetricBucketCard title="Expansion signals" buckets={analyticsSummary.expansionSignals} />
|
||||||
|
<MetricBucketCard title="Pricing conversion by plan" buckets={analyticsSummary.pricingConversionByPlan} />
|
||||||
|
<MetricBucketCard title="Quota exhaustion events" buckets={analyticsSummary.quotaExhaustionEvents} />
|
||||||
|
<MetricBucketCard title="Upgrade triggers" buckets={analyticsSummary.upgradeTriggers} />
|
||||||
|
<MetricBucketCard title="Add-on attach" buckets={analyticsSummary.addonAttach} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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">Billing Support Visibility</p>
|
||||||
|
<h2 className="text-lg font-semibold text-stone-950">Workspace lookup and detail</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detailLoading ? <Loader2 className="h-4 w-4 animate-spin text-stone-500" /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search workspace, customer ref, or subscription ref"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => void handleSearchWorkspaces()} disabled={workspacesLoading}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workspacesError ? <Alert className="mt-4" variant="error">{workspacesError}</Alert> : null}
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{workspacesLoading && workspaces.length === 0 ? <LoadingState message="Loading billing workspaces..." /> : null}
|
||||||
|
{workspaces.map((workspace) => (
|
||||||
|
<button
|
||||||
|
key={workspace.workspaceId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleLoadWorkspaceDetail(workspace.workspaceId)}
|
||||||
|
className="w-full rounded-2xl border border-stone-200 p-4 text-left transition hover:border-stone-300 hover:bg-stone-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-900">{workspace.workspaceName}</p>
|
||||||
|
<p className="text-xs text-stone-500">{workspace.planCode || 'No plan'} - {formatBillingStatusLabel(workspace.status)}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={workspace.billingSyncStatus === 'error' ? 'danger' : workspace.billingSyncStatus === 'stale' ? 'warning' : 'success'}>
|
||||||
|
{workspace.billingSyncStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!workspacesLoading && workspaces.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 workspaces found for this search.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4">
|
||||||
|
{!selectedWorkspace ? (
|
||||||
|
<p className="text-sm text-stone-600">Select a workspace to review billing state, pending plan fields, and recent timeline events.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-900">{selectedWorkspace.summary.workspaceName}</p>
|
||||||
|
<p className="text-stone-600">{selectedWorkspace.summary.planCode || 'No plan'} - {formatBillingStatusLabel(selectedWorkspace.summary.status)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-stone-700">
|
||||||
|
<div>Period ends: <span className="font-medium text-stone-900">{formatDateLabel(selectedWorkspace.billing.currentPeriodEndsAt)}</span></div>
|
||||||
|
<div>Grace period ends: <span className="font-medium text-stone-900">{formatDateLabel(selectedWorkspace.billing.gracePeriodEndsAt)}</span></div>
|
||||||
|
<div>Pending plan code: <span className="font-medium text-stone-900">{selectedWorkspace.billing.pendingPlanCode || 'None'}</span></div>
|
||||||
|
<div>Pending effective at: <span className="font-medium text-stone-900">{formatDateLabel(selectedWorkspace.billing.pendingPlanEffectiveAt)}</span></div>
|
||||||
|
<div>External customer ref: <span className="font-medium text-stone-900">{selectedWorkspace.summary.externalCustomerRef || 'Not set'}</span></div>
|
||||||
|
<div>External subscription ref: <span className="font-medium text-stone-900">{selectedWorkspace.summary.externalSubscriptionRef || 'Not set'}</span></div>
|
||||||
|
<div>Usage period id: <span className="font-medium text-stone-900">{selectedWorkspace.usagePeriodId || 'Not active'}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-900">Recent timeline summary</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{selectedWorkspace.timeline.slice(0, 10).map((entry) => (
|
||||||
|
<div key={entry.id} className="rounded-xl border border-stone-200 bg-white px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-medium text-stone-900">{entry.eventType}</span>
|
||||||
|
<span className="text-xs text-stone-500">{formatDateLabel(entry.occurredAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selectedWorkspace.timeline.length === 0 ? <p className="text-stone-600">No timeline events recorded.</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricBucketCard({ title, buckets }: { title: string; buckets: Array<{ key: string; count: number }> }) {
|
||||||
|
return (
|
||||||
|
<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">{title}</p>
|
||||||
|
<Badge>{buckets.reduce((sum, bucket) => sum + bucket.count, 0)} total</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{buckets.length === 0 ? <p className="text-sm text-stone-500">No data for this window.</p> : null}
|
||||||
|
{buckets.slice(0, 6).map((bucket) => (
|
||||||
|
<div key={bucket.key} className="flex items-center justify-between gap-3 rounded-xl bg-stone-50 px-3 py-2 text-sm">
|
||||||
|
<span className="truncate text-stone-700">{bucket.key}</span>
|
||||||
|
<span className="font-semibold text-stone-900">{bucket.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react';
|
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound, ShieldCheck } from 'lucide-react';
|
||||||
import type { SessionUser } from '../../shared/types';
|
import type { SessionUser } from '../../shared/types';
|
||||||
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
|
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
|
||||||
import { cn } from '../lib/cn';
|
import { cn } from '../lib/cn';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
|
|
||||||
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account';
|
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account' | 'admin';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
@@ -25,6 +25,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
|
|||||||
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'map', name: 'Map View', icon: MapIcon },
|
{ id: 'map', name: 'Map View', icon: MapIcon },
|
||||||
{ id: 'account', name: 'Account', icon: UserRound },
|
{ id: 'account', name: 'Account', icon: UserRound },
|
||||||
|
...(user.isAdmin ? ([{ id: 'admin', name: 'Admin', icon: ShieldCheck }] as const) : []),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0];
|
const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0];
|
||||||
@@ -108,7 +109,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
|
|||||||
<main className="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-16 lg:pb-0">{children}</main>
|
<main className="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-16 lg:pb-0">{children}</main>
|
||||||
|
|
||||||
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-stone-200 bg-white/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.5rem)] pt-2 backdrop-blur lg:hidden">
|
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-stone-200 bg-white/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.5rem)] pt-2 backdrop-blur lg:hidden">
|
||||||
<div className="grid grid-cols-5 gap-1">
|
<div className={cn('grid gap-1', user.isAdmin ? 'grid-cols-6' : 'grid-cols-5')}>
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive = activeTab === item.id;
|
const isActive = activeTab === item.id;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AccountPageData,
|
AccountPageData,
|
||||||
BillingAdminWorkspaceDetailResponse,
|
|
||||||
BillingAdminWorkspaceListResponse,
|
|
||||||
BillingCheckoutSessionResponse,
|
BillingCheckoutSessionResponse,
|
||||||
BillingDebugData,
|
BillingDebugData,
|
||||||
BillingPortalSessionResponse,
|
BillingPortalSessionResponse,
|
||||||
@@ -46,12 +44,3 @@ export async function createBillingPortalSession() {
|
|||||||
export async function getBillingDebugData() {
|
export async function getBillingDebugData() {
|
||||||
return apiRequest<BillingDebugData>('/billing/debug');
|
return apiRequest<BillingDebugData>('/billing/debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAdminBillingWorkspaces(query?: string) {
|
|
||||||
const search = query?.trim() ? `?query=${encodeURIComponent(query.trim())}` : '';
|
|
||||||
return apiRequest<BillingAdminWorkspaceListResponse>(`/admin/billing/workspaces${search}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminBillingWorkspaceDetail(workspaceId: string) {
|
|
||||||
return apiRequest<BillingAdminWorkspaceDetailResponse>(`/admin/billing/workspaces/${workspaceId}`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type {
|
||||||
|
AdminApplicationAdminResponse,
|
||||||
|
AdminApplicationAdminsListResponse,
|
||||||
|
AdminAnalyticsSummary,
|
||||||
|
ApplicationAdminStatus,
|
||||||
|
BillingAdminWorkspaceDetailResponse,
|
||||||
|
BillingAdminWorkspaceListResponse,
|
||||||
|
} from '../../shared/types';
|
||||||
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
|
export async function getAdminAnalyticsSummary(days = 30) {
|
||||||
|
const params = new URLSearchParams({ days: String(days) });
|
||||||
|
const response = await apiRequest<{ summary: AdminAnalyticsSummary }>(`/admin/analytics/summary?${params.toString()}`);
|
||||||
|
return response.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminBillingWorkspaces(query?: string) {
|
||||||
|
const search = query?.trim() ? `?query=${encodeURIComponent(query.trim())}` : '';
|
||||||
|
return apiRequest<BillingAdminWorkspaceListResponse>(`/admin/billing/workspaces${search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminBillingWorkspaceDetail(workspaceId: string) {
|
||||||
|
return apiRequest<BillingAdminWorkspaceDetailResponse>(`/admin/billing/workspaces/${workspaceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listApplicationAdmins() {
|
||||||
|
return apiRequest<AdminApplicationAdminsListResponse>('/admin/access/admins');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertApplicationAdmin(email: string) {
|
||||||
|
return apiRequest<AdminApplicationAdminResponse>('/admin/access/admins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateApplicationAdminStatus(adminId: string, status: ApplicationAdminStatus) {
|
||||||
|
return apiRequest<AdminApplicationAdminResponse>(`/admin/access/admins/${adminId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user