Public Access
1
0

feat: split admin into dedicated web surface

This commit is contained in:
pguerrerox
2026-05-28 23:04:03 +00:00
parent ce49497a6a
commit d71f2f1f8a
13 changed files with 374 additions and 23 deletions
+4 -2
View File
@@ -1,10 +1,12 @@
# App runtime # App runtime
NODE_ENV="development" NODE_ENV="development"
# Frontend env vars for the Vite app # Frontend env vars for the Vite apps
# Use "/api" for same-origin nginx proxy setups (app/admin subdomain or docker web surfaces).
VITE_API_BASE_URL="http://localhost:4000/api" VITE_API_BASE_URL="http://localhost:4000/api"
VITE_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY" VITE_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY"
WEB_PORT="3000" WEB_PORT="3000"
ADMIN_WEB_PORT="3001"
# Backend env vars # Backend env vars
## For Docker Compose deployments, point DATABASE_URL at the internal "db" host. ## For Docker Compose deployments, point DATABASE_URL at the internal "db" host.
@@ -13,7 +15,7 @@ DATABASE_URL="postgres://postgres:postgres@localhost:5432/leads4less"
COOKIE_SECRET="CHANGE_ME_IN_LOCAL_ENV" COOKIE_SECRET="CHANGE_ME_IN_LOCAL_ENV"
APP_HOST="0.0.0.0" APP_HOST="0.0.0.0"
APP_PORT="4000" APP_PORT="4000"
APP_ORIGIN="http://localhost:3000" APP_ORIGIN="http://localhost:3000,http://localhost:3001"
SESSION_TTL_DAYS="30" SESSION_TTL_DAYS="30"
GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY" GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
build/ build/
dist/ dist/
dist-admin/
dist-server/ dist-server/
coverage/ coverage/
.DS_Store .DS_Store
+5 -1
View File
@@ -11,12 +11,16 @@ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npm run build && npm run build:api RUN npm run build && npm run build:admin && npm run build:api
FROM nginx:alpine AS web FROM nginx:alpine AS web
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.default.conf /etc/nginx/conf.d/default.conf COPY nginx.default.conf /etc/nginx/conf.d/default.conf
FROM nginx:alpine AS web-admin
COPY --from=build /app/dist-admin /usr/share/nginx/html
COPY nginx.admin.conf /etc/nginx/conf.d/default.conf
FROM node:22-alpine AS runtime-base FROM node:22-alpine AS runtime-base
WORKDIR /app WORKDIR /app
+17 -7
View File
@@ -19,8 +19,10 @@ LocaleScope is a React + Vite app for researching local markets, saving business
- `COOKIE_SECRET` - `COOKIE_SECRET`
- `GOOGLE_MAPS_SERVER_KEY` - `GOOGLE_MAPS_SERVER_KEY`
- Stripe env vars below if you want to test billing locally - Stripe env vars below if you want to test billing locally
3. Run the frontend: 3. Run the user frontend:
`npm run dev:web` `npm run dev:web`
4. Run the admin frontend (dedicated surface):
`npm run dev:admin`
The local backend and scripts load both `.env` and `.env.local`, with `.env.local` taking precedence, so you can keep the full local setup in one file during development. The local backend and scripts load both `.env` and `.env.local`, with `.env.local` taking precedence, so you can keep the full local setup in one file during development.
@@ -42,8 +44,8 @@ If you open the app from another machine on your LAN, set `VITE_API_BASE_URL` an
- [ ] 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 - [ ] 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` - [ ] Run migrations: `npm run migrate`
- [ ] Set `ALLOW_ADMIN_BOOTSTRAP=true` and define `ADMIN_BOOTSTRAP_TOKEN` - [ ] 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` - [ ] Start app web, admin web, API, and worker: `npm run dev:web`, `npm run dev:admin`, `npm run dev:api`, `npm run dev:worker`
- [ ] Visit `/auth` and create the first site admin - [ ] Visit the admin surface and create the first site admin
- [ ] Disable bootstrap after first admin creation: `ALLOW_ADMIN_BOOTSTRAP=false` - [ ] Disable bootstrap after first admin creation: `ALLOW_ADMIN_BOOTSTRAP=false`
- [ ] Verify admin billing access at `/api/admin/billing/workspaces` - [ ] Verify admin billing access at `/api/admin/billing/workspaces`
@@ -56,7 +58,7 @@ Bootstrap mode is only needed when no active application admin exists.
1. Run migrations first: `npm run migrate` 1. Run migrations first: `npm run migrate`
2. Set `ALLOW_ADMIN_BOOTSTRAP=true` and `ADMIN_BOOTSTRAP_TOKEN` in your env file 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 3. Visit the dedicated admin surface, then create the first account in "Create first site admin" mode
4. After the first admin is created, set `ALLOW_ADMIN_BOOTSTRAP=false` 4. After the first admin is created, set `ALLOW_ADMIN_BOOTSTRAP=false`
## Stripe Billing Setup ## Stripe Billing Setup
@@ -127,18 +129,26 @@ Safe mutation pilot:
- `POSTGRES_USER` - `POSTGRES_USER`
- `POSTGRES_PASSWORD` - `POSTGRES_PASSWORD`
- `WEB_PORT` - `WEB_PORT`
- `ADMIN_WEB_PORT`
- `APP_PORT` - `APP_PORT`
- `COOKIE_SECRET` - `COOKIE_SECRET`
- `GOOGLE_MAPS_SERVER_KEY` - `GOOGLE_MAPS_SERVER_KEY`
- `VITE_API_BASE_URL` (use `/api` when web and API share a domain via the nginx proxy) - `VITE_API_BASE_URL` (use `/api` when web/admin and API share a domain via the nginx proxy)
- `VITE_GOOGLE_MAPS_PLATFORM_KEY` - `VITE_GOOGLE_MAPS_PLATFORM_KEY`
2. Create the shared Docker network if it does not exist: 2. Create the shared Docker network if it does not exist:
`docker network create locale-all || true` `docker network create locale-all || true`
3. Build and start the full stack: 3. Build and start the full stack:
`docker compose up --build` `docker compose up --build`
The Compose stack starts PostGIS, waits for the database to become healthy, runs migrations automatically, and then starts the API, worker, and web containers. The Compose stack starts PostGIS, waits for the database to become healthy, runs migrations automatically, and then starts the API, worker, user-web, and admin-web containers.
With the default `.env.example` values, the app is exposed on `http://localhost:3000` and the API on `http://localhost:4000`. With the default `.env.example` values, the user app is exposed on `http://localhost:3000`, the admin app on `http://localhost:3001`, and the API on `http://localhost:4000`.
## App and Admin Surfaces
- User and admin UIs now run on separate frontend surfaces/build targets.
- The main app no longer links to or renders admin routes/tabs.
- Admin tasks live on the dedicated admin surface and continue using the same API/session auth.
- `APP_ORIGIN` should include both origins for CORS/session checks, for example: `https://localescope.com,https://admin.localescope.com`.
## Database Layout ## Database Layout
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leads4less Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/admin-main.tsx"></script>
</body>
</html>
+16
View File
@@ -98,6 +98,22 @@ services:
ports: ports:
- "${WEB_PORT}:80" - "${WEB_PORT}:80"
admin-web:
build:
context: .
target: web-admin
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
VITE_GOOGLE_MAPS_PLATFORM_KEY: ${VITE_GOOGLE_MAPS_PLATFORM_KEY}
depends_on:
api:
condition: service_started
restart: unless-stopped
networks:
- locale-all
ports:
- "${ADMIN_WEB_PORT}:80"
volumes: volumes:
leads4less-db: leads4less-db:
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri /index.html;
}
}
+3 -1
View File
@@ -6,12 +6,14 @@
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3000 --host=0.0.0.0",
"dev:web": "vite --port=3000 --host=0.0.0.0", "dev:web": "vite --port=3000 --host=0.0.0.0",
"dev:admin": "vite --config vite.admin.config.ts --port=3001 --host=0.0.0.0",
"dev:api": "tsx --tsconfig tsconfig.server.json server/src/index.ts", "dev:api": "tsx --tsconfig tsconfig.server.json server/src/index.ts",
"dev:worker": "tsx --tsconfig tsconfig.server.json server/src/worker.ts", "dev:worker": "tsx --tsconfig tsconfig.server.json server/src/worker.ts",
"build": "vite build", "build": "vite build",
"build:admin": "vite build --config vite.admin.config.ts",
"build:api": "tsc -p tsconfig.server.json", "build:api": "tsc -p tsconfig.server.json",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist dist-server", "clean": "rm -rf dist dist-admin dist-server",
"lint": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit", "lint": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit",
"migrate": "tsx --tsconfig tsconfig.server.json db/scripts/migrate.ts", "migrate": "tsx --tsconfig tsconfig.server.json db/scripts/migrate.ts",
"import:postal": "tsx --tsconfig tsconfig.server.json db/scripts/import-postal-areas.ts", "import:postal": "tsx --tsconfig tsconfig.server.json db/scripts/import-postal-areas.ts",
+258
View File
@@ -0,0 +1,258 @@
import { ShieldAlert, ShieldCheck } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import type { AdminBootstrapStatusResponse, SessionUser } from '../shared/types';
import { AdminPage } from './components/AdminPage';
import { Alert, Button, Card, FieldLabel, Input } from './components/ui';
import {
claimAdminBootstrap,
getAdminBootstrapStatus,
getLocalSessionUser,
signInWithLocalAuth,
signOutWithLocalAuth,
} from './lib/auth';
import { hasApiConfig } from './lib/api';
export function AdminPortal() {
const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
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(() => {
let isMounted = true;
const loadSession = async () => {
try {
const sessionUser = await getLocalSessionUser();
if (isMounted) {
setUser(sessionUser);
}
} catch (nextError) {
if (isMounted) {
setError(nextError instanceof Error ? nextError.message : 'Failed to load session.');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
void loadSession();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
if (user) {
return;
}
let isMounted = true;
const loadBootstrapStatus = async () => {
setBootstrapLoading(true);
try {
const status = await getAdminBootstrapStatus();
if (isMounted) {
setBootstrapStatus(status);
}
} catch (nextError) {
if (isMounted) {
setError(nextError instanceof Error ? nextError.message : 'Failed to load bootstrap status.');
}
} finally {
if (isMounted) {
setBootstrapLoading(false);
}
}
};
void loadBootstrapStatus();
return () => {
isMounted = false;
};
}, [user]);
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
const handleSignIn = async () => {
if (isBootstrapRequired && bootstrapLoading) {
return;
}
setError(null);
setNotice(null);
setIsSubmitting(true);
try {
if (isBootstrapRequired) {
const nextUser = await claimAdminBootstrap({
email,
password,
displayName: displayName.trim() || undefined,
bootstrapToken,
});
setUser(nextUser);
setNotice('First site admin created and signed in.');
return;
}
const nextUser = await signInWithLocalAuth({ email, password });
setUser(nextUser);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Authentication failed.');
} finally {
setIsSubmitting(false);
}
};
const handleSignOut = async () => {
const sessionId = user?.sessionId;
setError(null);
setNotice(null);
setUser(null);
try {
await signOutWithLocalAuth(sessionId);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Failed to sign out.');
}
};
if (loading) {
return <FullscreenCard title="Loading admin portal..." />;
}
if (!hasApiConfig) {
return (
<FullscreenCard title="Local API config required">
<p className="text-sm text-stone-600">Set VITE_API_BASE_URL and restart the admin frontend.</p>
</FullscreenCard>
);
}
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center bg-stone-50 p-4">
<Card className="w-full max-w-md p-6">
<div className="mb-5 flex items-center gap-3">
<div className="rounded-xl bg-stone-900 p-2 text-white">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<h1 className="text-lg font-semibold text-stone-900">LocaleScope Admin</h1>
<p className="text-sm text-stone-500">Sign in to access admin operations.</p>
</div>
</div>
{notice ? <Alert variant="success">{notice}</Alert> : null}
{error ? <Alert variant="error" className="mt-3">{error}</Alert> : null}
<div className="mt-4 space-y-4">
<div>
<FieldLabel>Email</FieldLabel>
<Input id="admin-email" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} />
</div>
<div>
<FieldLabel>Password</FieldLabel>
<Input
id="admin-password"
type="password"
autoComplete={isBootstrapRequired ? 'new-password' : 'current-password'}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
{isBootstrapRequired ? (
<>
<div>
<FieldLabel>Display name</FieldLabel>
<Input
id="admin-display-name"
autoComplete="name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</div>
<div>
<FieldLabel>Bootstrap token</FieldLabel>
<Input
id="admin-bootstrap-token"
type="password"
value={bootstrapToken}
onChange={(event) => setBootstrapToken(event.target.value)}
/>
</div>
</>
) : null}
{isBootstrapRequired && !bootstrapLoading ? (
<Alert variant="info">Bootstrap is required because no active app admin exists.</Alert>
) : null}
<Button type="button" className="w-full" disabled={isSubmitting || bootstrapLoading} onClick={() => void handleSignIn()}>
{isSubmitting ? (isBootstrapRequired ? 'Creating admin...' : 'Signing in...') : isBootstrapRequired ? 'Create first site admin' : 'Sign in'}
</Button>
</div>
</Card>
</div>
);
}
if (!user.isAdmin) {
return (
<FullscreenCard title="Access denied">
<div className="space-y-3">
<p className="text-sm text-stone-600">Your account does not have admin access.</p>
{error ? <Alert variant="error">{error}</Alert> : null}
<Button type="button" variant="secondary" onClick={() => void handleSignOut()}>
Sign out
</Button>
</div>
</FullscreenCard>
);
}
return (
<div className="min-h-screen bg-stone-50">
<header className="border-b border-stone-200 bg-white px-4 py-3 sm:px-6">
<div className="mx-auto flex w-full max-w-7xl items-center justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">LocaleScope</p>
<p className="text-lg font-semibold text-stone-900">Admin Portal</p>
</div>
<Button type="button" variant="secondary" onClick={() => void handleSignOut()}>
Sign out
</Button>
</div>
</header>
<AdminPage />
</div>
);
}
function FullscreenCard({ title, children }: { title: string; children?: ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-stone-50 p-4">
<Card className="w-full max-w-xl p-6">
<div className="mb-3 flex items-center gap-3">
<div className="rounded-xl bg-stone-900 p-2 text-white">
<ShieldAlert className="h-5 w-5" />
</div>
<h1 className="text-lg font-semibold text-stone-900">{title}</h1>
</div>
{children}
</Card>
</div>
);
}
-8
View File
@@ -17,7 +17,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Layout, type AppTab } from './components/Layout'; import { Layout, type AppTab } from './components/Layout';
import { AccountPage } from './components/AccountPage'; import { AccountPage } from './components/AccountPage';
import { AdminPage } from './components/AdminPage';
import { Dashboard } from './components/Dashboard'; import { Dashboard } from './components/Dashboard';
import { MapView } from './components/MapView'; import { MapView } from './components/MapView';
import { PricingCards } from './components/PricingCards'; import { PricingCards } from './components/PricingCards';
@@ -109,12 +108,6 @@ export default function App() {
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true; const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
useEffect(() => {
if (activeTab === 'admin' && !user?.isAdmin) {
setActiveTab('account');
}
}, [activeTab, user]);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -372,7 +365,6 @@ export default function App() {
onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)} onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)}
/> />
)} )}
{activeTab === 'admin' && user.isAdmin && <AdminPage />}
</Layout> </Layout>
</APIProvider> </APIProvider>
); );
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { AdminPortal } from './AdminPortal';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AdminPortal />
</StrictMode>,
);
+3 -4
View File
@@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound, ShieldCheck } from 'lucide-react'; import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react';
import type { SessionUser } 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';
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account' | 'admin'; export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account';
interface LayoutProps { interface LayoutProps {
user: SessionUser; user: SessionUser;
@@ -25,7 +25,6 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
{ id: 'map', name: 'Map View', icon: MapIcon }, { id: 'map', name: 'Map View', icon: MapIcon },
{ id: 'account', name: 'Account', icon: UserRound }, { id: 'account', name: 'Account', icon: UserRound },
...(user.isAdmin ? ([{ id: 'admin', name: 'Admin', icon: ShieldCheck }] as const) : []),
] as const; ] as const;
const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0]; const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0];
@@ -109,7 +108,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
<main className="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-16 lg:pb-0">{children}</main> <main className="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-16 lg:pb-0">{children}</main>
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-stone-200 bg-white/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.5rem)] pt-2 backdrop-blur lg:hidden"> <nav className="fixed inset-x-0 bottom-0 z-40 border-t border-stone-200 bg-white/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.5rem)] pt-2 backdrop-blur lg:hidden">
<div className={cn('grid gap-1', user.isAdmin ? 'grid-cols-6' : 'grid-cols-5')}> <div className="grid grid-cols-5 gap-1">
{navigation.map((item) => { {navigation.map((item) => {
const isActive = activeTab === item.id; const isActive = activeTab === item.id;
+25
View File
@@ -0,0 +1,25 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
allowedHosts: ['project-1.duramente.com'],
hmr: process.env.DISABLE_HMR !== 'true',
port: 3001,
host: '0.0.0.0',
},
build: {
outDir: 'dist-admin',
rollupOptions: {
input: 'admin.html',
},
},
});