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
+135 -16
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,14 +745,19 @@ 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.'}
</p>
</div>
{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">
{authMode === 'sign_up' ? <UserPlus className="h-6 w-6" /> : <LogIn className="h-6 w-6" />}
</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,13 +862,19 @@ function AuthPage(props: {
) : (
<LogIn className="h-5 w-5" />
)}
{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'}
</Button>
</form>
</Card>
+8 -3
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,9 +88,14 @@ 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>
</div>
</div>
</div>
<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';
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;
}