feat: add first-run admin bootstrap flow and site-admin badge
This commit is contained in:
+135
-16
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user