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
+5 -1
View File
@@ -33,6 +33,10 @@ STRIPE_BILLING_PORTAL_CONFIGURATION_ID="bpc_CHANGE_ME"
ADMIN_EMAILS="ops@example.com" ADMIN_EMAILS="ops@example.com"
BILLING_ADMIN_EMAILS="ops@example.com" # Deprecated fallback; use ADMIN_EMAILS 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 # Docker Compose database env vars
POSTGRES_DB="leads4less" POSTGRES_DB="leads4less"
POSTGRES_USER="postgres" POSTGRES_USER="postgres"
@@ -40,4 +44,4 @@ POSTGRES_PASSWORD="CHANGE_ME_IN_LOCAL_ENV"
PG_BOSS_SCHEMA="pgboss" PG_BOSS_SCHEMA="pgboss"
# Example Compose DATABASE_URL # Example Compose DATABASE_URL
# DATABASE_URL="postgres://postgres:CHANGE_ME_IN_LOCAL_ENV@db:5432/leads4less" # DATABASE_URL="postgres://postgres:CHANGE_ME_IN_LOCAL_ENV@db:5432/leads4less"
+5
View File
@@ -9,12 +9,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added ### 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 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 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 ### Changed
- Replaced env-only billing-admin authorization with application-admin checks backed by database records, while keeping env allowlist fallback support for rollout safety. - Replaced env-only billing-admin authorization with application-admin checks backed by database records, while keeping env allowlist fallback support for rollout safety.
- 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 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). - 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. - 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] ## [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: 4. Start the worker:
`npm run dev: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 Billing Setup
Stripe is now the active payments integration for self-serve subscriptions and one-time export packs. 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: Configure these server-side env vars to enable billing routes:
- `STRIPE_PUBLISHABLE_KEY`
- `STRIPE_SECRET_KEY` - `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET` - `STRIPE_WEBHOOK_SECRET`
- `STRIPE_PRICE_STARTER_MONTHLY` - `STRIPE_PRICE_STARTER_MONTHLY`
+20
View File
@@ -87,6 +87,22 @@
- entitlement/usage treatment when the target plan is below current usage - entitlement/usage treatment when the target plan is below current usage
- account messaging for pending downgrade state - account messaging for pending downgrade state
### Admin Dashboard / Console Incremental Plan
- [ ] 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 ## 12) [DEFER] Operational Enforcement Follow-Up
- [ ] Add queue prioritization by plan tier. - [ ] Add queue prioritization by plan tier.
- [ ] Add throttling/fair-usage controls. - [ ] Add throttling/fair-usage controls.
@@ -116,6 +132,10 @@
- [ ] Phase 5: finalize add-on strategy before wiring payment products. - [ ] Phase 5: finalize add-on strategy before wiring payment products.
- [ ] Phase 6: integrate payments and subscription lifecycle handling. - [ ] 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 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 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 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. - [ ] 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 { healthRoutes } from './routes/health.js';
import { searchJobRoutes } from './routes/search-jobs.js'; import { searchJobRoutes } from './routes/search-jobs.js';
import { analyticsRoutes } from './routes/analytics.js'; import { analyticsRoutes } from './routes/analytics.js';
import { adminBootstrapRoutes } from './routes/admin-bootstrap.js';
function parseAllowedOrigins(rawOrigins: string) { function parseAllowedOrigins(rawOrigins: string) {
return rawOrigins return rawOrigins
@@ -56,6 +57,7 @@ export async function buildApp() {
await app.register(searchJobRoutes, { prefix: '/api' }); await app.register(searchJobRoutes, { prefix: '/api' });
await app.register(deepResearchRoutes, { prefix: '/api' }); await app.register(deepResearchRoutes, { prefix: '/api' });
await app.register(analyticsRoutes, { prefix: '/api' }); await app.register(analyticsRoutes, { prefix: '/api' });
await app.register(adminBootstrapRoutes, { prefix: '/api' });
return app; return app;
} }
+36
View File
@@ -64,6 +64,42 @@ export async function isApplicationAdmin(db: DbClient, email: string) {
return isAdminEmailAllowlisted(email); 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) { export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
const user = request.authUser; const user = request.authUser;
+14 -1
View File
@@ -15,6 +15,7 @@ type SessionRow = {
avatar_url: string | null; avatar_url: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
is_admin: boolean;
}; };
function hashSessionToken(token: string) { function hashSessionToken(token: string) {
@@ -37,6 +38,7 @@ function mapSessionRow(row: SessionRow): SessionUser {
avatarUrl: row.avatar_url, avatarUrl: row.avatar_url,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_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 tokenHash = hashSessionToken(token);
const result = await db.query<SessionRow>( 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 from public.sessions s
join public.users u on u.id = s.user_id 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() where s.token_hash = $1 and s.expires_at > now()
limit 1 limit 1
`, `,
+6
View File
@@ -40,6 +40,8 @@ const envSchema = z.object({
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(), STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
ADMIN_EMAILS: z.string().optional(), ADMIN_EMAILS: z.string().optional(),
BILLING_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>; export type AppEnv = z.infer<typeof envSchema>;
@@ -80,3 +82,7 @@ export function isBillingAdminEmail(email: string) {
return allowlist.includes(email.trim().toLowerCase()); 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 { ZodError, z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js'; import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.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 { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
import { createDefaultWorkspaceForUser } from '../account/repository.js'; import { createDefaultWorkspaceForUser } from '../account/repository.js';
import { getDbPool } from '../db/pool.js'; import { getDbPool } from '../db/pool.js';
@@ -36,8 +37,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return { user: null }; return { user: null };
} }
const user = await getSessionUserByToken(getDbPool(), token); const db = getDbPool();
return { user }; 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) => { app.post('/auth/signup', async (request, reply) => {
@@ -73,8 +81,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
} }
setSessionCookie(reply, session.token, session.expiresAt); 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) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid signup payload.' }); 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)); const session = await createSession(db, user.id, getRequestMetadata(request));
setSessionCookie(reply, session.token, session.expiresAt); 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) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid login payload.' }); 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 { export interface SessionUser extends AppUser {
sessionId: string; 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'; export type WorkspaceType = 'personal' | 'company';
+135 -16
View File
@@ -25,8 +25,15 @@ import { ResearchWorkspace } from './components/ResearchWorkspace';
import { ResultsWorkspace } from './components/ResultsWorkspace'; import { ResultsWorkspace } from './components/ResultsWorkspace';
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui'; import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
import type { BillingInterval, PlanCode } from '../shared/billing/plans'; import type { BillingInterval, PlanCode } from '../shared/billing/plans';
import type { SessionUser } from '../shared/types'; import type { AdminBootstrapStatusResponse, SessionUser } from '../shared/types';
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth'; import {
claimAdminBootstrap,
getAdminBootstrapStatus,
getLocalSessionUser,
signInWithLocalAuth,
signOutWithLocalAuth,
signUpWithLocalAuth,
} from './lib/auth';
import { hasApiConfig } from './lib/api'; import { hasApiConfig } from './lib/api';
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_PLATFORM_KEY ?? ''; 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 [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
const [bootstrapToken, setBootstrapToken] = useState('');
const [bootstrapStatus, setBootstrapStatus] = useState<AdminBootstrapStatusResponse | null>(null);
const [bootstrapLoading, setBootstrapLoading] = useState(false);
useEffect(() => { useEffect(() => {
const handlePopState = () => { 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(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -119,11 +168,29 @@ export default function App() {
}; };
const handleLogin = async () => { const handleLogin = async () => {
if (isBootstrapRequired && bootstrapLoading) {
return;
}
setAuthError(null); setAuthError(null);
setAuthNotice(null); setAuthNotice(null);
setIsAuthenticating(true); setIsAuthenticating(true);
try { 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') { if (authMode === 'sign_up') {
const nextUser = await signUpWithLocalAuth({ const nextUser = await signUpWithLocalAuth({
email, email,
@@ -164,6 +231,8 @@ export default function App() {
setSelectedJobIds([]); setSelectedJobIds([]);
setUser(null); setUser(null);
setActiveTab('setup'); setActiveTab('setup');
setBootstrapStatus(null);
setBootstrapToken('');
try { try {
await signOutWithLocalAuth(sessionId); await signOutWithLocalAuth(sessionId);
@@ -207,13 +276,18 @@ export default function App() {
if (publicPage === 'auth') { if (publicPage === 'auth') {
return ( return (
<AuthPage <AuthPage
authMode={authMode} authMode={isBootstrapRequired ? 'sign_up' : authMode}
authError={authError} authError={authError}
authNotice={authNotice} authNotice={authNotice}
bootstrapLoading={bootstrapLoading}
bootstrapRequired={isBootstrapRequired}
bootstrapToken={bootstrapToken}
displayName={displayName} displayName={displayName}
email={email} email={email}
isAuthenticating={isAuthenticating} isAuthenticating={isAuthenticating}
bootstrapEnabled={bootstrapStatus?.bootstrapEnabled ?? false}
password={password} password={password}
onBootstrapTokenChange={setBootstrapToken}
onDisplayNameChange={setDisplayName} onDisplayNameChange={setDisplayName}
onEmailChange={setEmail} onEmailChange={setEmail}
onPasswordChange={setPassword} onPasswordChange={setPassword}
@@ -569,10 +643,15 @@ function AuthPage(props: {
authMode: 'sign_in' | 'sign_up'; authMode: 'sign_in' | 'sign_up';
authError: string | null; authError: string | null;
authNotice: string | null; authNotice: string | null;
bootstrapEnabled: boolean;
bootstrapLoading: boolean;
bootstrapRequired: boolean;
bootstrapToken: string;
displayName: string; displayName: string;
email: string; email: string;
isAuthenticating: boolean; isAuthenticating: boolean;
password: string; password: string;
onBootstrapTokenChange: (value: string) => void;
onDisplayNameChange: (value: string) => void; onDisplayNameChange: (value: string) => void;
onEmailChange: (value: string) => void; onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void; onPasswordChange: (value: string) => void;
@@ -584,10 +663,15 @@ function AuthPage(props: {
authMode, authMode,
authError, authError,
authNotice, authNotice,
bootstrapEnabled,
bootstrapLoading,
bootstrapRequired,
bootstrapToken,
displayName, displayName,
email, email,
isAuthenticating, isAuthenticating,
password, password,
onBootstrapTokenChange,
onDisplayNameChange, onDisplayNameChange,
onEmailChange, onEmailChange,
onPasswordChange, onPasswordChange,
@@ -623,12 +707,16 @@ function AuthPage(props: {
<div className="space-y-8"> <div className="space-y-8">
<Badge variant="primary" className="px-4 py-2 text-sm"> <Badge variant="primary" className="px-4 py-2 text-sm">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Secure access to your intelligence workspace {bootstrapRequired ? 'First-run initialization mode' : 'Secure access to your intelligence workspace'}
</Badge> </Badge>
<div className="space-y-5"> <div className="space-y-5">
<h1 className="max-w-3xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl"> <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> </h1>
<p className="max-w-2xl text-lg leading-8 text-stone-600 sm:text-xl"> <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. Access research runs, deep research coverage, clean map review, and saved business history from one focused operating surface.
@@ -657,14 +745,19 @@ function AuthPage(props: {
{authMode === 'sign_up' ? 'Create account' : 'Sign in'} {authMode === 'sign_up' ? 'Create account' : 'Sign in'}
</h2> </h2>
<p className="mt-2 text-sm text-stone-600"> <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
</p> ? 'Bootstrap is required because no active app admin exists.'
</div> : 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"> <div className="rounded-2xl bg-stone-100 p-3 text-stone-900">
{authMode === 'sign_up' ? <UserPlus className="h-6 w-6" /> : <LogIn className="h-6 w-6" />} {authMode === 'sign_up' ? <UserPlus className="h-6 w-6" /> : <LogIn className="h-6 w-6" />}
</div> </div>
</div> </div>
{!bootstrapRequired && (
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-stone-100 p-1"> <div className="grid grid-cols-2 gap-2 rounded-2xl bg-stone-100 p-1">
<button <button
type="button" type="button"
@@ -685,6 +778,13 @@ function AuthPage(props: {
Sign Up Sign Up
</button> </button>
</div> </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 && ( {authError && (
<Alert variant="error" title="Authentication Error" className="mt-5"> <Alert variant="error" title="Authentication Error" className="mt-5">
@@ -713,6 +813,19 @@ function AuthPage(props: {
</div> </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> <div>
<FieldLabel>Email</FieldLabel> <FieldLabel>Email</FieldLabel>
<Input <Input
@@ -738,7 +851,7 @@ function AuthPage(props: {
<Button <Button
type="submit" type="submit"
disabled={isAuthenticating} disabled={isAuthenticating || (bootstrapRequired && bootstrapLoading)}
size="lg" size="lg"
className="w-full rounded-2xl bg-stone-900 hover:bg-stone-800" className="w-full rounded-2xl bg-stone-900 hover:bg-stone-800"
> >
@@ -749,13 +862,19 @@ function AuthPage(props: {
) : ( ) : (
<LogIn className="h-5 w-5" /> <LogIn className="h-5 w-5" />
)} )}
{isAuthenticating {bootstrapRequired
? authMode === 'sign_up' ? isAuthenticating
? 'Creating account...' ? 'Creating first admin...'
: 'Signing in...' : bootstrapLoading
: authMode === 'sign_up' ? 'Checking bootstrap status...'
? 'Create Account' : 'Create First Admin'
: 'Sign In'} : isAuthenticating
? authMode === 'sign_up'
? 'Creating account...'
: 'Signing in...'
: authMode === 'sign_up'
? 'Create Account'
: 'Sign In'}
</Button> </Button>
</form> </form>
</Card> </Card>
+8 -3
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react'; import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react';
import type { AppUser } from '../../shared/types'; import type { SessionUser } from '../../shared/types';
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth'; import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
import { Button } from './ui'; import { Button } from './ui';
@@ -8,7 +8,7 @@ import { Button } from './ui';
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account'; export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account';
interface LayoutProps { interface LayoutProps {
user: AppUser; user: SessionUser;
activeTab: AppTab; activeTab: AppTab;
setActiveTab: (tab: AppTab) => void; setActiveTab: (tab: AppTab) => void;
onLogout: () => void; onLogout: () => void;
@@ -88,9 +88,14 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<div className="min-w-0 flex-1"> <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-sm font-semibold text-stone-900">{userDisplayName}</p>
<p className="truncate text-xs text-stone-500">{user.email}</p> <p className="truncate text-xs text-stone-500">{user.email}</p>
</div> </div>
</div> </div>
</div> </div>
<Button onClick={onLogout} variant="secondary" className="w-full justify-start gap-3 text-stone-600 hover:text-stone-900"> <Button onClick={onLogout} variant="secondary" className="w-full justify-start gap-3 text-stone-600 hover:text-stone-900">
+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'; import { apiRequest } from './api';
export function getUserDisplayName(user: AppUser | SessionUser | null): string { export function getUserDisplayName(user: AppUser | SessionUser | null): string {
@@ -38,3 +44,16 @@ export async function signOutWithLocalAuth(sessionId?: string) {
body: JSON.stringify(sessionId ? { sessionId } : {}), 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;
}