Public Access
1
0

feat: add first-run admin bootstrap flow and site-admin badge

This commit is contained in:
pguerrerox
2026-05-25 20:20:42 +00:00
parent f5e7e966e3
commit 232342d6a1
14 changed files with 437 additions and 26 deletions
+4
View File
@@ -33,6 +33,10 @@ STRIPE_BILLING_PORTAL_CONFIGURATION_ID="bpc_CHANGE_ME"
ADMIN_EMAILS="ops@example.com"
BILLING_ADMIN_EMAILS="ops@example.com" # Deprecated fallback; use ADMIN_EMAILS
# Admin bootstraping
ALLOW_ADMIN_BOOTSTRAP="true" # Disable after first admin is created
ADMIN_BOOTSTRAP_TOKEN="CHANGE_ME_BOOTSTRAP_TOKEN"
# Docker Compose database env vars
POSTGRES_DB="leads4less"
POSTGRES_USER="postgres"
+5
View File
@@ -9,12 +9,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- Added migrations to enforce workspace membership roles as `owner`/`member` only and to introduce DB-backed application-admin identities with access-audit storage.
- Added centralized admin authorization and audit helpers so internal `/admin/*` routes can use one shared access check and log admin support activity.
- 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.
### Changed
- Replaced env-only billing-admin authorization with application-admin checks backed by database records, while keeping env allowlist fallback support for rollout safety.
- Updated account and workspace permission handling so only workspace owners can manage workspace settings, and admin tooling visibility is driven by the new app-admin identity.
- Updated environment and setup docs for Stripe keys plus the new preferred `ADMIN_EMAILS` allowlist variable (with `BILLING_ADMIN_EMAILS` retained as a deprecated fallback).
- Reorganized the pricing rollout tracker to reflect completed phases, deferred work, and the new app-admin and workspace-role migration milestones.
- Updated auth/session responses to include canonical admin-status checks so admin UI state stays consistent after refresh and login.
- Updated README and TODO planning docs for phased admin-console rollout and the first-run operational checklist.
## [2026-05-22]
+24
View File
@@ -36,12 +36,36 @@ If you open the app from another machine on your LAN, set `VITE_API_BASE_URL` an
4. Start the worker:
`npm run dev:worker`
## First Run Checklist
- [ ] Install dependencies: `npm install`
- [ ] Copy `.env.example` to `.env.local` and fill required keys: `DATABASE_URL`, `COOKIE_SECRET`, `GOOGLE_MAPS_SERVER_KEY`, `VITE_GOOGLE_MAPS_PLATFORM_KEY`, and Stripe keys if testing billing
- [ ] Run migrations: `npm run migrate`
- [ ] Set `ALLOW_ADMIN_BOOTSTRAP=true` and define `ADMIN_BOOTSTRAP_TOKEN`
- [ ] Start web, API, and worker: `npm run dev:web`, `npm run dev:api`, `npm run dev:worker`
- [ ] Visit `/auth` and create the first site admin
- [ ] Disable bootstrap after first admin creation: `ALLOW_ADMIN_BOOTSTRAP=false`
- [ ] Verify admin billing access at `/api/admin/billing/workspaces`
## First-run Admin Bootstrap
Bootstrap mode is only needed when no active application admin exists.
- The DB-backed `application_admins` table is the primary source of truth for app-admin access.
- `ADMIN_EMAILS` and `BILLING_ADMIN_EMAILS` are fallback allowlists during rollout.
1. Run migrations first: `npm run migrate`
2. Set `ALLOW_ADMIN_BOOTSTRAP=true` and `ADMIN_BOOTSTRAP_TOKEN` in your env file
3. Visit `/auth`, then create the first account in "Create first site admin" mode
4. After the first admin is created, set `ALLOW_ADMIN_BOOTSTRAP=false`
## Stripe Billing Setup
Stripe is now the active payments integration for self-serve subscriptions and one-time export packs.
Configure these server-side env vars to enable billing routes:
- `STRIPE_PUBLISHABLE_KEY`
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `STRIPE_PRICE_STARTER_MONTHLY`
+20
View File
@@ -87,6 +87,22 @@
- 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.
- [ ] 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
- [ ] Add queue prioritization by plan tier.
- [ ] Add throttling/fair-usage controls.
@@ -116,6 +132,10 @@
- [ ] Phase 5: finalize add-on strategy before wiring payment products.
- [ ] Phase 6: integrate payments and subscription lifecycle handling.
- [ ] Phase 7: harden post-payments lifecycle handling, wire real billing CTAs, and add pragmatic admin billing visibility before broader commercialization work.
- [ ] Phase 7a: ship dedicated read-only admin console and migrate existing admin billing tools out of the account page.
- [ ] Phase 7b: ship app-admin identity management APIs/UI with last-admin lockout protection and audit logging.
- [ ] Phase 7c: ship admin audit explorer and support diagnostics views.
- [ ] Phase 7d: evaluate controlled admin write-actions only after policy/runbook readiness.
- [ ] Phase 8: expand analytics, ops, and revenue instrumentation around the live billing and upgrade flows.
- [ ] Phase 9: launch collaboration, API, enrichment, and enterprise features as architecture matures.
- [ ] Phase 10: complete deferred operational enforcement work such as queue prioritization, throttling, and backend export enforcement when runtime scale justifies it.
+2
View File
@@ -9,6 +9,7 @@ import { billingRoutes } from './routes/billing.js';
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';
function parseAllowedOrigins(rawOrigins: string) {
return rawOrigins
@@ -56,6 +57,7 @@ export async function buildApp() {
await app.register(searchJobRoutes, { prefix: '/api' });
await app.register(deepResearchRoutes, { prefix: '/api' });
await app.register(analyticsRoutes, { prefix: '/api' });
await app.register(adminBootstrapRoutes, { prefix: '/api' });
return app;
}
+36
View File
@@ -64,6 +64,42 @@ export async function isApplicationAdmin(db: DbClient, email: string) {
return isAdminEmailAllowlisted(email);
}
export async function getActiveApplicationAdminCount(db: DbClient): Promise<number> {
const result = await db.query<{ count: string }>(
`
select count(*)::text as count
from public.application_admins
where status = 'active'
`,
);
return Number(result.rows[0]?.count ?? '0');
}
export async function isBootstrapRequired(db: DbClient): Promise<boolean> {
return (await getActiveApplicationAdminCount(db)) === 0;
}
export async function createApplicationAdmin(
db: DbClient,
input: { email: string; createdByUserId?: string | null; permissionsJson?: unknown[] },
): Promise<void> {
const normalizedEmail = normalizeEmail(input.email);
await db.query(
`
insert into public.application_admins (email, email_normalized, status, permissions_json, created_by_user_id)
values ($1, $2, 'active', $3::jsonb, $4)
on conflict (email_normalized)
do update set
email = excluded.email,
status = 'active',
updated_at = now()
`,
[input.email.trim(), normalizedEmail, JSON.stringify(input.permissionsJson ?? []), input.createdByUserId ?? null],
);
}
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
const user = request.authUser;
+14 -1
View File
@@ -15,6 +15,7 @@ type SessionRow = {
avatar_url: string | null;
created_at: string;
updated_at: string;
is_admin: boolean;
};
function hashSessionToken(token: string) {
@@ -37,6 +38,7 @@ function mapSessionRow(row: SessionRow): SessionUser {
avatarUrl: row.avatar_url,
createdAt: row.created_at,
updatedAt: row.updated_at,
isAdmin: row.is_admin,
};
}
@@ -103,9 +105,20 @@ export async function getSessionUserByToken(db: DbClient, token: string) {
const tokenHash = hashSessionToken(token);
const result = await db.query<SessionRow>(
`
select s.id as session_id, u.id, u.email, u.display_name, u.avatar_url, u.created_at, u.updated_at
select
s.id as session_id,
u.id,
u.email,
u.display_name,
u.avatar_url,
u.created_at,
u.updated_at,
(aa.id is not null) as is_admin
from public.sessions s
join public.users u on u.id = s.user_id
left join public.application_admins aa
on aa.email_normalized = lower(trim(u.email))
and aa.status = 'active'
where s.token_hash = $1 and s.expires_at > now()
limit 1
`,
+6
View File
@@ -40,6 +40,8 @@ const envSchema = z.object({
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
ADMIN_EMAILS: z.string().optional(),
BILLING_ADMIN_EMAILS: z.string().optional(),
ALLOW_ADMIN_BOOTSTRAP: z.coerce.boolean().default(false),
ADMIN_BOOTSTRAP_TOKEN: z.string().optional(),
});
export type AppEnv = z.infer<typeof envSchema>;
@@ -80,3 +82,7 @@ export function isBillingAdminEmail(email: string) {
return allowlist.includes(email.trim().toLowerCase());
}
export function isAdminBootstrapEnabled() {
return getEnv().ALLOW_ADMIN_BOOTSTRAP;
}
+131
View File
@@ -0,0 +1,131 @@
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { ZodError, z } from 'zod';
import type { AdminBootstrapClaimResponse, AdminBootstrapStatusResponse } from '../../../shared/types.js';
import { createDefaultWorkspaceForUser } from '../account/repository.js';
import { createApplicationAdmin, isApplicationAdmin, isBootstrapRequired, recordAdminAccessAudit } from '../auth/admin.js';
import { hashPassword } from '../auth/passwords.js';
import { createSession, setSessionCookie } from '../auth/sessions.js';
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
import { getEnv, isAdminBootstrapEnabled } from '../config/env.js';
import { getDbPool } from '../db/pool.js';
const claimSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
displayName: z.string().trim().min(1).max(120).optional(),
bootstrapToken: z.string().min(1),
});
class HttpError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}
function getRequestMetadata(request: FastifyRequest) {
return {
userAgent: request.headers['user-agent'],
ipAddress: request.ip || null,
};
}
export const adminBootstrapRoutes: FastifyPluginAsync = async (app) => {
app.get('/admin/bootstrap/status', async (_request, reply) => {
const db = getDbPool();
const response: AdminBootstrapStatusResponse = {
bootstrapRequired: await isBootstrapRequired(db),
bootstrapEnabled: isAdminBootstrapEnabled(),
};
return reply.send(response);
});
app.post('/admin/bootstrap/claim', async (request, reply) => {
try {
const payload = claimSchema.parse(request.body);
const env = getEnv();
if (!isAdminBootstrapEnabled()) {
return reply.code(403).send({ error: 'Admin bootstrap is disabled.' });
}
if (!env.ADMIN_BOOTSTRAP_TOKEN || payload.bootstrapToken !== env.ADMIN_BOOTSTRAP_TOKEN) {
return reply.code(403).send({ error: 'Invalid bootstrap token.' });
}
const db = getDbPool();
const client = await db.connect();
try {
await client.query('begin');
await client.query('lock table public.application_admins in share row exclusive mode');
if (!(await isBootstrapRequired(client))) {
throw new HttpError(409, 'Admin bootstrap is no longer required.');
}
const existingUser = await getUserByEmail(client, payload.email);
if (existingUser) {
throw new HttpError(409, 'An account with that email already exists.');
}
const passwordHash = await hashPassword(payload.password);
const user = await createUser(client, {
email: payload.email,
passwordHash,
displayName: payload.displayName,
});
await createDefaultWorkspaceForUser(client, user);
await createApplicationAdmin(client, {
email: user.email,
createdByUserId: user.id,
});
const session = await createSession(client, user.id, getRequestMetadata(request));
await recordAdminAccessAudit(client, {
actorUserId: user.id,
actorEmail: user.email,
route: '/api/admin/bootstrap/claim',
action: 'bootstrap_admin_claimed',
metadataJson: {
bootstrapRequiredAtClaim: true,
},
});
await client.query('commit');
setSessionCookie(reply, session.token, session.expiresAt);
const response: AdminBootstrapClaimResponse = {
user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin: await isApplicationAdmin(client, user.email) },
};
return reply.code(201).send(response);
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid bootstrap claim payload.' });
}
if (error instanceof HttpError) {
return reply.code(error.statusCode).send({ error: error.message });
}
if ((error as { code?: string }).code === '23505') {
return reply.code(409).send({ error: 'An account with that email already exists.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to claim admin bootstrap.' });
}
});
};
+14 -4
View File
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { ZodError, z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
import { isApplicationAdmin } from '../auth/admin.js';
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
import { createDefaultWorkspaceForUser } from '../account/repository.js';
import { getDbPool } from '../db/pool.js';
@@ -36,8 +37,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return { user: null };
}
const user = await getSessionUserByToken(getDbPool(), token);
return { user };
const db = getDbPool();
const user = await getSessionUserByToken(db, token);
if (!user) {
return { user: null };
}
const isAdmin = await isApplicationAdmin(db, user.email);
return { user: { ...user, isAdmin } };
});
app.post('/auth/signup', async (request, reply) => {
@@ -73,8 +81,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
setSessionCookie(reply, session.token, session.expiresAt);
const isAdmin = await isApplicationAdmin(db, user.email);
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } });
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin } });
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid signup payload.' });
@@ -106,8 +115,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const session = await createSession(db, user.id, getRequestMetadata(request));
setSessionCookie(reply, session.token, session.expiresAt);
const isAdmin = await isApplicationAdmin(db, user.email);
return { user: { ...toAppUser(user), sessionId: session.sessionId } };
return { user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin } };
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid login payload.' });
+17
View File
@@ -15,6 +15,23 @@ export interface AppUser {
export interface SessionUser extends AppUser {
sessionId: string;
isAdmin?: boolean;
}
export interface AdminBootstrapStatusResponse {
bootstrapRequired: boolean;
bootstrapEnabled: boolean;
}
export interface AdminBootstrapClaimRequest {
email: string;
password: string;
displayName?: string;
bootstrapToken: string;
}
export interface AdminBootstrapClaimResponse {
user: SessionUser;
}
export type WorkspaceType = 'personal' | 'company';
+127 -8
View File
@@ -25,8 +25,15 @@ import { ResearchWorkspace } from './components/ResearchWorkspace';
import { ResultsWorkspace } from './components/ResultsWorkspace';
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
import type { BillingInterval, PlanCode } from '../shared/billing/plans';
import type { SessionUser } from '../shared/types';
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
import type { AdminBootstrapStatusResponse, SessionUser } from '../shared/types';
import {
claimAdminBootstrap,
getAdminBootstrapStatus,
getLocalSessionUser,
signInWithLocalAuth,
signOutWithLocalAuth,
signUpWithLocalAuth,
} from './lib/auth';
import { hasApiConfig } from './lib/api';
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_PLATFORM_KEY ?? '';
@@ -46,6 +53,9 @@ export default function App() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [bootstrapToken, setBootstrapToken] = useState('');
const [bootstrapStatus, setBootstrapStatus] = useState<AdminBootstrapStatusResponse | null>(null);
const [bootstrapLoading, setBootstrapLoading] = useState(false);
useEffect(() => {
const handlePopState = () => {
@@ -59,6 +69,45 @@ export default function App() {
};
}, []);
useEffect(() => {
if (user || publicPage !== 'auth') {
return;
}
let isMounted = true;
const loadBootstrapStatus = async () => {
setBootstrapLoading(true);
try {
const status = await getAdminBootstrapStatus();
if (!isMounted) {
return;
}
setBootstrapStatus(status);
} catch (error) {
if (!isMounted) {
return;
}
setAuthError(error instanceof Error ? error.message : 'Failed to load bootstrap status.');
} finally {
if (isMounted) {
setBootstrapLoading(false);
}
}
};
void loadBootstrapStatus();
return () => {
isMounted = false;
};
}, [publicPage, user]);
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
useEffect(() => {
let isMounted = true;
@@ -119,11 +168,29 @@ export default function App() {
};
const handleLogin = async () => {
if (isBootstrapRequired && bootstrapLoading) {
return;
}
setAuthError(null);
setAuthNotice(null);
setIsAuthenticating(true);
try {
if (isBootstrapRequired) {
const nextUser = await claimAdminBootstrap({
email,
password,
displayName: displayName.trim() || undefined,
bootstrapToken,
});
setUser(nextUser);
setAuthNotice('First site admin created and signed in.');
navigatePublicPage('landing', setPublicPage);
return;
}
if (authMode === 'sign_up') {
const nextUser = await signUpWithLocalAuth({
email,
@@ -164,6 +231,8 @@ export default function App() {
setSelectedJobIds([]);
setUser(null);
setActiveTab('setup');
setBootstrapStatus(null);
setBootstrapToken('');
try {
await signOutWithLocalAuth(sessionId);
@@ -207,13 +276,18 @@ export default function App() {
if (publicPage === 'auth') {
return (
<AuthPage
authMode={authMode}
authMode={isBootstrapRequired ? 'sign_up' : authMode}
authError={authError}
authNotice={authNotice}
bootstrapLoading={bootstrapLoading}
bootstrapRequired={isBootstrapRequired}
bootstrapToken={bootstrapToken}
displayName={displayName}
email={email}
isAuthenticating={isAuthenticating}
bootstrapEnabled={bootstrapStatus?.bootstrapEnabled ?? false}
password={password}
onBootstrapTokenChange={setBootstrapToken}
onDisplayNameChange={setDisplayName}
onEmailChange={setEmail}
onPasswordChange={setPassword}
@@ -569,10 +643,15 @@ function AuthPage(props: {
authMode: 'sign_in' | 'sign_up';
authError: string | null;
authNotice: string | null;
bootstrapEnabled: boolean;
bootstrapLoading: boolean;
bootstrapRequired: boolean;
bootstrapToken: string;
displayName: string;
email: string;
isAuthenticating: boolean;
password: string;
onBootstrapTokenChange: (value: string) => void;
onDisplayNameChange: (value: string) => void;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
@@ -584,10 +663,15 @@ function AuthPage(props: {
authMode,
authError,
authNotice,
bootstrapEnabled,
bootstrapLoading,
bootstrapRequired,
bootstrapToken,
displayName,
email,
isAuthenticating,
password,
onBootstrapTokenChange,
onDisplayNameChange,
onEmailChange,
onPasswordChange,
@@ -623,12 +707,16 @@ function AuthPage(props: {
<div className="space-y-8">
<Badge variant="primary" className="px-4 py-2 text-sm">
<Sparkles className="h-4 w-4" />
Secure access to your intelligence workspace
{bootstrapRequired ? 'First-run initialization mode' : 'Secure access to your intelligence workspace'}
</Badge>
<div className="space-y-5">
<h1 className="max-w-3xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
{authMode === 'sign_up' ? 'Create your workspace and start researching local markets.' : 'Sign in and continue your market intelligence workflow.'}
{bootstrapRequired
? 'Create first site admin'
: authMode === 'sign_up'
? 'Create your workspace and start researching local markets.'
: 'Sign in and continue your market intelligence workflow.'}
</h1>
<p className="max-w-2xl text-lg leading-8 text-stone-600 sm:text-xl">
Access research runs, deep research coverage, clean map review, and saved business history from one focused operating surface.
@@ -657,7 +745,11 @@ function AuthPage(props: {
{authMode === 'sign_up' ? 'Create account' : 'Sign in'}
</h2>
<p className="mt-2 text-sm text-stone-600">
{authMode === 'sign_up' ? 'Set up your account to start using LocaleScope.' : 'Use your account to continue where you left off.'}
{bootstrapRequired
? 'Bootstrap is required because no active app admin exists.'
: authMode === 'sign_up'
? 'Set up your account to start using LocaleScope.'
: 'Use your account to continue where you left off.'}
</p>
</div>
<div className="rounded-2xl bg-stone-100 p-3 text-stone-900">
@@ -665,6 +757,7 @@ function AuthPage(props: {
</div>
</div>
{!bootstrapRequired && (
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-stone-100 p-1">
<button
type="button"
@@ -685,6 +778,13 @@ function AuthPage(props: {
Sign Up
</button>
</div>
)}
{bootstrapRequired && !bootstrapEnabled && (
<Alert variant="error" title="Bootstrap Disabled" className="mt-5">
<p>Bootstrap is required, but ALLOW_ADMIN_BOOTSTRAP is disabled.</p>
</Alert>
)}
{authError && (
<Alert variant="error" title="Authentication Error" className="mt-5">
@@ -713,6 +813,19 @@ function AuthPage(props: {
</div>
)}
{bootstrapRequired && (
<div>
<FieldLabel>Bootstrap token</FieldLabel>
<Input
type="password"
required
value={bootstrapToken}
onChange={(event) => onBootstrapTokenChange(event.target.value)}
placeholder="Enter ADMIN_BOOTSTRAP_TOKEN"
/>
</div>
)}
<div>
<FieldLabel>Email</FieldLabel>
<Input
@@ -738,7 +851,7 @@ function AuthPage(props: {
<Button
type="submit"
disabled={isAuthenticating}
disabled={isAuthenticating || (bootstrapRequired && bootstrapLoading)}
size="lg"
className="w-full rounded-2xl bg-stone-900 hover:bg-stone-800"
>
@@ -749,7 +862,13 @@ function AuthPage(props: {
) : (
<LogIn className="h-5 w-5" />
)}
{isAuthenticating
{bootstrapRequired
? isAuthenticating
? 'Creating first admin...'
: bootstrapLoading
? 'Checking bootstrap status...'
: 'Create First Admin'
: isAuthenticating
? authMode === 'sign_up'
? 'Creating account...'
: 'Signing in...'
+7 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react';
import type { AppUser } from '../../shared/types';
import type { SessionUser } from '../../shared/types';
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
import { cn } from '../lib/cn';
import { Button } from './ui';
@@ -8,7 +8,7 @@ import { Button } from './ui';
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account';
interface LayoutProps {
user: AppUser;
user: SessionUser;
activeTab: AppTab;
setActiveTab: (tab: AppTab) => void;
onLogout: () => void;
@@ -88,6 +88,11 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
referrerPolicy="no-referrer"
/>
<div className="min-w-0 flex-1">
{user.isAdmin ? (
<span className="mb-1 inline-flex items-center rounded-full border border-stone-300 bg-white px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-stone-700">
Site Admin
</span>
) : null}
<p className="truncate text-sm font-semibold text-stone-900">{userDisplayName}</p>
<p className="truncate text-xs text-stone-500">{user.email}</p>
</div>
+20 -1
View File
@@ -1,4 +1,10 @@
import type { AppUser, SessionUser } from '../../shared/types';
import type {
AdminBootstrapClaimRequest,
AdminBootstrapClaimResponse,
AdminBootstrapStatusResponse,
AppUser,
SessionUser,
} from '../../shared/types';
import { apiRequest } from './api';
export function getUserDisplayName(user: AppUser | SessionUser | null): string {
@@ -38,3 +44,16 @@ export async function signOutWithLocalAuth(sessionId?: string) {
body: JSON.stringify(sessionId ? { sessionId } : {}),
});
}
export async function getAdminBootstrapStatus(): Promise<AdminBootstrapStatusResponse> {
return apiRequest<AdminBootstrapStatusResponse>('/admin/bootstrap/status');
}
export async function claimAdminBootstrap(payload: AdminBootstrapClaimRequest): Promise<SessionUser> {
const response = await apiRequest<AdminBootstrapClaimResponse>('/admin/bootstrap/claim', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.user;
}