diff --git a/.env.example b/.env.example index c083aed..4ae7439 100644 --- a/.env.example +++ b/.env.example @@ -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" @@ -40,4 +44,4 @@ POSTGRES_PASSWORD="CHANGE_ME_IN_LOCAL_ENV" PG_BOSS_SCHEMA="pgboss" # Example Compose DATABASE_URL -# DATABASE_URL="postgres://postgres:CHANGE_ME_IN_LOCAL_ENV@db:5432/leads4less" \ No newline at end of file +# DATABASE_URL="postgres://postgres:CHANGE_ME_IN_LOCAL_ENV@db:5432/leads4less" diff --git a/CHANGELOG.md b/CHANGELOG.md index da8ef0c..08222e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/README.md b/README.md index 2631702..af01ca3 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/TODO-pricing.md b/TODO-pricing.md index 587b285..42ddd76 100644 --- a/TODO-pricing.md +++ b/TODO-pricing.md @@ -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. diff --git a/server/src/app.ts b/server/src/app.ts index b033d75..f84ff28 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; } diff --git a/server/src/auth/admin.ts b/server/src/auth/admin.ts index 65a3b92..a9b6fae 100644 --- a/server/src/auth/admin.ts +++ b/server/src/auth/admin.ts @@ -64,6 +64,42 @@ export async function isApplicationAdmin(db: DbClient, email: string) { return isAdminEmailAllowlisted(email); } +export async function getActiveApplicationAdminCount(db: DbClient): Promise { + 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 { + return (await getActiveApplicationAdminCount(db)) === 0; +} + +export async function createApplicationAdmin( + db: DbClient, + input: { email: string; createdByUserId?: string | null; permissionsJson?: unknown[] }, +): Promise { + 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; diff --git a/server/src/auth/sessions.ts b/server/src/auth/sessions.ts index b1d027f..1abbd2d 100644 --- a/server/src/auth/sessions.ts +++ b/server/src/auth/sessions.ts @@ -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( ` - 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 `, diff --git a/server/src/config/env.ts b/server/src/config/env.ts index ec0696f..693e1fa 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -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; @@ -80,3 +82,7 @@ export function isBillingAdminEmail(email: string) { return allowlist.includes(email.trim().toLowerCase()); } + +export function isAdminBootstrapEnabled() { + return getEnv().ALLOW_ADMIN_BOOTSTRAP; +} diff --git a/server/src/routes/admin-bootstrap.ts b/server/src/routes/admin-bootstrap.ts new file mode 100644 index 0000000..25aa441 --- /dev/null +++ b/server/src/routes/admin-bootstrap.ts @@ -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.' }); + } + }); +}; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 3141df1..abc8b72 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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.' }); diff --git a/shared/types.ts b/shared/types.ts index 27ddecb..1aa3e4b 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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'; diff --git a/src/App.tsx b/src/App.tsx index 8175c2f..b0ac888 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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 ( 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: {
- Secure access to your intelligence workspace + {bootstrapRequired ? 'First-run initialization mode' : 'Secure access to your intelligence workspace'}

- {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.'}

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' ? '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.'} +

+
{authMode === 'sign_up' ? : }
+ {!bootstrapRequired && (
+ )} + + {bootstrapRequired && !bootstrapEnabled && ( + +

Bootstrap is required, but ALLOW_ADMIN_BOOTSTRAP is disabled.

+
+ )} {authError && ( @@ -713,6 +813,19 @@ function AuthPage(props: { )} + {bootstrapRequired && ( +
+ Bootstrap token + onBootstrapTokenChange(event.target.value)} + placeholder="Enter ADMIN_BOOTSTRAP_TOKEN" + /> +
+ )} +
Email @@ -749,13 +862,19 @@ function AuthPage(props: { ) : ( )} - {isAuthenticating - ? authMode === 'sign_up' - ? 'Creating account...' - : 'Signing in...' - : authMode === 'sign_up' - ? 'Create Account' - : 'Sign In'} + {bootstrapRequired + ? isAuthenticating + ? 'Creating first admin...' + : bootstrapLoading + ? 'Checking bootstrap status...' + : 'Create First Admin' + : isAuthenticating + ? authMode === 'sign_up' + ? 'Creating account...' + : 'Signing in...' + : authMode === 'sign_up' + ? 'Create Account' + : 'Sign In'} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 7b885a1..f46250e 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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,9 +88,14 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La referrerPolicy="no-referrer" />
+ {user.isAdmin ? ( + + Site Admin + + ) : null}

{userDisplayName}

{user.email}

-
+