diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cb7a7..26385a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `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 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 - 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. - 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. +- 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] diff --git a/TODO-pricing.md b/TODO-pricing.md index 5fdc970..2a9eea9 100644 --- a/TODO-pricing.md +++ b/TODO-pricing.md @@ -87,23 +87,23 @@ - entitlement/usage treatment when the target plan is below current usage - account messaging for pending downgrade state -### 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. -- [ ] 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. -- [ ] 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). -- [ ] 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). -- [ ] 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. +## 12) Admin Dashboard / Console Incremental Plan +- [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. +- [x] 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): Move existing admin billing visibility tools (workspace search + workspace detail) from Account page to admin page. +- [x] 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): Keep server-side `requireAdmin` as the source of truth (UI checks are convenience only). +- [x] 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 UI for managing app-admin identities with status visibility (active/disabled). +- [x] Phase B (Admin Access Management): Prevent accidental lockout with guardrails (e.g., disallow disabling the last active admin). +- [x] Phase B (Admin Access Management): Add explicit audit entries for admin identity mutations. - [ ] Phase C (Audit & Support Operations): Add admin audit log page/table with filters (actor, action, workspace, date window). - [ ] Phase C (Audit & Support Operations): Expose bootstrap/security posture checks in admin UI (bootstrap enabled state, fallback allowlist usage warnings). - [ ] Phase C (Audit & Support Operations): Add support-oriented diagnostics widgets (recent webhook issues, billing sync errors, timeline anomalies). - [ ] 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. -## 12) [DEFER] Operational Enforcement Follow-Up +## 13) [DEFER] Operational Enforcement Follow-Up - [ ] Add queue prioritization by plan tier. - [ ] Add throttling/fair-usage controls. - [ ] 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: 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. - [ ] If yes, define strict quantity cap (e.g. first 100-250 customers). - [ ] Define founder SKUs: @@ -122,7 +122,7 @@ - [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API. - [ ] 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 2: implement usage ledger and backend enforcement. - [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises. diff --git a/docker-compose.yml b/docker-compose.yml index ac60fe5..8c8edfe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ services: db: image: postgis/postgis:16-3.4 restart: unless-stopped + networks: + - locale-all environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} @@ -49,6 +51,8 @@ services: PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS} restart: unless-stopped + networks: + - locale-all ports: - "${APP_PORT}:${APP_PORT}" @@ -69,6 +73,8 @@ services: PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS} restart: unless-stopped + networks: + - locale-all web: build: @@ -81,8 +87,14 @@ services: api: condition: service_started restart: unless-stopped + networks: + - locale-all ports: - "${WEB_PORT}:80" volumes: leads4less-db: + +networks: + locale-all: + external: true diff --git a/server/src/app.ts b/server/src/app.ts index f84ff28..2d95507 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import { healthRoutes } from './routes/health.js'; import { searchJobRoutes } from './routes/search-jobs.js'; import { analyticsRoutes } from './routes/analytics.js'; import { adminBootstrapRoutes } from './routes/admin-bootstrap.js'; +import { adminAccessRoutes } from './routes/admin-access.js'; function parseAllowedOrigins(rawOrigins: string) { return rawOrigins @@ -58,6 +59,7 @@ export async function buildApp() { await app.register(deepResearchRoutes, { prefix: '/api' }); await app.register(analyticsRoutes, { prefix: '/api' }); await app.register(adminBootstrapRoutes, { prefix: '/api' }); + await app.register(adminAccessRoutes, { prefix: '/api' }); return app; } diff --git a/server/src/auth/admin.ts b/server/src/auth/admin.ts index a9b6fae..5825a45 100644 --- a/server/src/auth/admin.ts +++ b/server/src/auth/admin.ts @@ -1,5 +1,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import type { Pool, PoolClient } from 'pg'; +import type { ApplicationAdminSummary, ApplicationAdminStatus } from '../../../shared/types.js'; import { getAdminEmailAllowlist } from '../config/env.js'; import { getDbPool } from '../db/pool.js'; @@ -16,6 +17,18 @@ type ApplicationAdminRow = { 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 = { actorUserId?: string | null; actorEmail?: string | null; @@ -48,6 +61,76 @@ export async function getApplicationAdminByEmail(db: DbClient, email: string) { return result.rows[0] ?? null; } +export async function listApplicationAdmins(db: DbClient): Promise { + const result = await db.query( + ` + 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 { + const normalizedEmail = normalizeEmail(input.email); + const result = await db.query( + ` + 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 { + const result = await db.query( + ` + 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 { + 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) { const admin = await getApplicationAdminByEmail(db, email); diff --git a/server/src/routes/admin-access.ts b/server/src/routes/admin-access.ts new file mode 100644 index 0000000..74c3acb --- /dev/null +++ b/server/src/routes/admin-access.ts @@ -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.' }); + } + }); +}; diff --git a/shared/types.ts b/shared/types.ts index 1aa3e4b..967affd 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -34,6 +34,34 @@ export interface AdminBootstrapClaimResponse { 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 WorkspaceRole = 'owner' | 'member'; diff --git a/src/App.tsx b/src/App.tsx index b0ac888..42d817b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { } from 'lucide-react'; import { Layout, type AppTab } from './components/Layout'; import { AccountPage } from './components/AccountPage'; +import { AdminPage } from './components/AdminPage'; import { Dashboard } from './components/Dashboard'; import { MapView } from './components/MapView'; import { PricingCards } from './components/PricingCards'; @@ -108,6 +109,12 @@ export default function App() { const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true; + useEffect(() => { + if (activeTab === 'admin' && !user?.isAdmin) { + setActiveTab('account'); + } + }, [activeTab, user]); + useEffect(() => { let isMounted = true; @@ -365,6 +372,7 @@ export default function App() { onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)} /> )} + {activeTab === 'admin' && user.isAdmin && } ); diff --git a/src/components/AccountPage.tsx b/src/components/AccountPage.tsx index fab51d0..c805406 100644 --- a/src/components/AccountPage.tsx +++ b/src/components/AccountPage.tsx @@ -3,14 +3,12 @@ import { useEffect, useState } from 'react'; import { getEligibleAddonsForPlan } from '../../shared/billing/addons'; import { formatLifecycleDate } from '../../shared/billing/lifecycle'; 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 { createAddonCheckout, createBillingPortalSession, createSubscriptionCheckout, getAccountPageData, - getAdminBillingWorkspaceDetail, - listAdminBillingWorkspaces, updateAccountProfile, } from '../lib/account'; import { @@ -50,11 +48,6 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul const [billingAction, setBillingAction] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); - const [adminQuery, setAdminQuery] = useState(''); - const [adminWorkspaces, setAdminWorkspaces] = useState([]); - const [adminWorkspaceDetail, setAdminWorkspaceDetail] = useState(null); - const [adminLoading, setAdminLoading] = useState(false); - const isAdmin = account?.isAdmin ?? account?.isBillingAdmin ?? false; useEffect(() => { let isMounted = true; @@ -75,15 +68,6 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul setAvatarUrl(nextAccount.profile.avatarUrl ?? ''); setWorkspaceName(nextAccount.workspace.name); setNotice(getBillingReturnNotice()); - - if ((nextAccount.isAdmin ?? nextAccount.isBillingAdmin ?? false)) { - setAdminLoading(true); - const adminResponse = await listAdminBillingWorkspaces(); - if (isMounted) { - setAdminWorkspaces(adminResponse.workspaces); - } - setAdminLoading(false); - } } catch (nextError) { if (!isMounted) { return; @@ -236,34 +220,6 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul 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) { return ( @@ -634,73 +590,6 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul

{account.team.message}

- {isAdmin ? ( - -
-
-

Admin Billing

-

Support visibility

-
- {adminLoading ? : null} -
-
- setAdminQuery(event.target.value)} placeholder="Search workspace or Stripe id" /> - -
-
- {adminWorkspaces.map((workspace) => ( - - ))} -
- - {adminWorkspaceDetail ? ( -
-
-

{adminWorkspaceDetail.summary.workspaceName}

-

{adminWorkspaceDetail.summary.planCode || 'No plan'} · {adminWorkspaceDetail.summary.status}

-
-
-
Renewal: {formatDateLabel(adminWorkspaceDetail.billing.currentPeriodEndsAt)}
-
Grace ends: {formatDateLabel(adminWorkspaceDetail.billing.gracePeriodEndsAt)}
-
Pending plan: {adminWorkspaceDetail.billing.pendingPlanCode || 'None'}
-
Pending effective: {formatDateLabel(adminWorkspaceDetail.billing.pendingPlanEffectiveAt)}
-
Subscription ref: {adminWorkspaceDetail.summary.externalSubscriptionRef || 'Not set'}
-
Usage period id: {adminWorkspaceDetail.usagePeriodId || 'Not active'}
-
-
-

Recent timeline

-
- {adminWorkspaceDetail.timeline.map((entry) => ( -
-
- {entry.eventType} - {formatDateLabel(entry.occurredAt)} -
-
- ))} -
-
-
- ) : null} -
- ) : null} diff --git a/src/components/AdminPage.tsx b/src/components/AdminPage.tsx new file mode 100644 index 0000000..44a2846 --- /dev/null +++ b/src/components/AdminPage.tsx @@ -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(null); + const [summaryLoading, setSummaryLoading] = useState(true); + const [summaryError, setSummaryError] = useState(null); + + const [searchQuery, setSearchQuery] = useState(''); + const [workspaces, setWorkspaces] = useState([]); + const [selectedWorkspace, setSelectedWorkspace] = useState(null); + const [workspacesLoading, setWorkspacesLoading] = useState(true); + const [workspacesError, setWorkspacesError] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + const [admins, setAdmins] = useState([]); + const [adminsLoading, setAdminsLoading] = useState(true); + const [adminsError, setAdminsError] = useState(null); + const [adminMutationFeedback, setAdminMutationFeedback] = useState(null); + const [adminMutationError, setAdminMutationError] = useState(null); + const [adminEmailInput, setAdminEmailInput] = useState(''); + const [adminEmailSubmitting, setAdminEmailSubmitting] = useState(false); + const [statusMutationAdminId, setStatusMutationAdminId] = useState(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 ( + + + + + +
+
+
+ +
+
+

Admin Access Management

+

Application admin identities

+
+
+
+ +
+
+ Admin email + setAdminEmailInput(event.target.value)} + placeholder="name@company.com" + type="email" + /> +
+ +
+ + {adminsError ? {adminsError} : null} + {adminMutationError ? {adminMutationError} : null} + {adminMutationFeedback ? {adminMutationFeedback} : null} + +
+ {adminsLoading && admins.length === 0 ? : null} + {admins.map((admin) => { + const isMutatingStatus = statusMutationAdminId === admin.id; + const isActive = admin.status === 'active'; + return ( +
+
+
+

{admin.email}

+

Created {formatDateLabel(admin.createdAt)}

+
+
+ {admin.status} + +
+
+
+ ); + })} + {!adminsLoading && admins.length === 0 ? ( +

+ No application admins are configured. +

+ ) : null} +
+
+ + +
+
+
+ +
+
+

Analytics Summary

+

Admin metrics overview

+
+
+
+
+ Days + { + const numericValue = Number(event.target.value); + const normalizedValue = Number.isFinite(numericValue) ? numericValue : DEFAULT_SUMMARY_DAYS; + setSummaryDays(clampSummaryDays(normalizedValue)); + }} + className="w-24" + /> +
+ +
+
+ +
+ {summaryError ? {summaryError} : null} + {summaryLoading && !analyticsSummary ? : null} + {analyticsSummary ? ( +
+ + + + + + + +
+ ) : null} +
+
+ + +
+
+
+ +
+
+

Billing Support Visibility

+

Workspace lookup and detail

+
+
+ {detailLoading ? : null} +
+ +
+ setSearchQuery(event.target.value)} + placeholder="Search workspace, customer ref, or subscription ref" + /> + +
+ + {workspacesError ? {workspacesError} : null} + +
+
+ {workspacesLoading && workspaces.length === 0 ? : null} + {workspaces.map((workspace) => ( + + ))} + {!workspacesLoading && workspaces.length === 0 ? ( +

+ No workspaces found for this search. +

+ ) : null} +
+ +
+ {!selectedWorkspace ? ( +

Select a workspace to review billing state, pending plan fields, and recent timeline events.

+ ) : ( +
+
+

{selectedWorkspace.summary.workspaceName}

+

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

+
+
+
Period ends: {formatDateLabel(selectedWorkspace.billing.currentPeriodEndsAt)}
+
Grace period ends: {formatDateLabel(selectedWorkspace.billing.gracePeriodEndsAt)}
+
Pending plan code: {selectedWorkspace.billing.pendingPlanCode || 'None'}
+
Pending effective at: {formatDateLabel(selectedWorkspace.billing.pendingPlanEffectiveAt)}
+
External customer ref: {selectedWorkspace.summary.externalCustomerRef || 'Not set'}
+
External subscription ref: {selectedWorkspace.summary.externalSubscriptionRef || 'Not set'}
+
Usage period id: {selectedWorkspace.usagePeriodId || 'Not active'}
+
+ +
+

Recent timeline summary

+
+ {selectedWorkspace.timeline.slice(0, 10).map((entry) => ( +
+
+ {entry.eventType} + {formatDateLabel(entry.occurredAt)} +
+
+ ))} + {selectedWorkspace.timeline.length === 0 ?

No timeline events recorded.

: null} +
+
+
+ )} +
+
+
+
+
+ ); +} + +function MetricBucketCard({ title, buckets }: { title: string; buckets: Array<{ key: string; count: number }> }) { + return ( +
+
+

{title}

+ {buckets.reduce((sum, bucket) => sum + bucket.count, 0)} total +
+
+ {buckets.length === 0 ?

No data for this window.

: null} + {buckets.slice(0, 6).map((bucket) => ( +
+ {bucket.key} + {bucket.count} +
+ ))} +
+
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index f46250e..cd19d2b 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,11 +1,11 @@ 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 { getUserAvatarUrl, getUserDisplayName } from '../lib/auth'; import { cn } from '../lib/cn'; import { Button } from './ui'; -export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account'; +export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account' | 'admin'; interface LayoutProps { user: SessionUser; @@ -25,6 +25,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La { id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard }, { id: 'map', name: 'Map View', icon: MapIcon }, { id: 'account', name: 'Account', icon: UserRound }, + ...(user.isAdmin ? ([{ id: 'admin', name: 'Admin', icon: ShieldCheck }] as const) : []), ] as const; const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0]; @@ -108,7 +109,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
{children}