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 `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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 { 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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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