Public Access
1
0

feat: add admin console and app-admin access management

This commit is contained in:
pguerrerox
2026-05-26 18:46:24 +00:00
parent f1c3e2db7d
commit bdeda4902e
13 changed files with 756 additions and 139 deletions
+5
View File
@@ -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]
+13 -13
View File
@@ -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.
+12
View File
@@ -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
+2
View File
@@ -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;
}
+83
View File
@@ -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<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) {
const admin = await getApplicationAdminByEmail(db, email);
+124
View File
@@ -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.' });
}
});
};
+28
View File
@@ -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';
+8
View File
@@ -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 && <AdminPage />}
</Layout>
</APIProvider>
);
+1 -112
View File
@@ -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<string | null>(null);
const [error, setError] = 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(() => {
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 (
<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>
</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>
+434
View File
@@ -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>
);
}
+4 -3
View File
@@ -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
<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">
<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) => {
const isActive = activeTab === item.id;
-11
View File
@@ -1,7 +1,5 @@
import type {
AccountPageData,
BillingAdminWorkspaceDetailResponse,
BillingAdminWorkspaceListResponse,
BillingCheckoutSessionResponse,
BillingDebugData,
BillingPortalSessionResponse,
@@ -46,12 +44,3 @@ export async function createBillingPortalSession() {
export async function getBillingDebugData() {
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}`);
}
+42
View File
@@ -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 }),
});
}