From d71f2f1f8a46d0a989802ceb0426cd986c067363 Mon Sep 17 00:00:00 2001 From: pguerrerox Date: Thu, 28 May 2026 23:04:03 +0000 Subject: [PATCH] feat: split admin into dedicated web surface --- .env.example | 6 +- .gitignore | 1 + Dockerfile | 6 +- README.md | 24 ++-- admin.html | 12 ++ docker-compose.yml | 16 +++ nginx.admin.conf | 20 +++ package.json | 4 +- src/AdminPortal.tsx | 258 ++++++++++++++++++++++++++++++++++++++ src/App.tsx | 8 -- src/admin-main.tsx | 10 ++ src/components/Layout.tsx | 7 +- vite.admin.config.ts | 25 ++++ 13 files changed, 374 insertions(+), 23 deletions(-) create mode 100644 admin.html create mode 100644 nginx.admin.conf create mode 100644 src/AdminPortal.tsx create mode 100644 src/admin-main.tsx create mode 100644 vite.admin.config.ts diff --git a/.env.example b/.env.example index 4ae7439..9ce1f0b 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,12 @@ # App runtime 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_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY" WEB_PORT="3000" +ADMIN_WEB_PORT="3001" # Backend env vars ## 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" APP_HOST="0.0.0.0" APP_PORT="4000" -APP_ORIGIN="http://localhost:3000" +APP_ORIGIN="http://localhost:3000,http://localhost:3001" SESSION_TTL_DAYS="30" GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY" diff --git a/.gitignore b/.gitignore index 4e3861c..f8e9c31 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ build/ dist/ +dist-admin/ dist-server/ coverage/ .DS_Store diff --git a/Dockerfile b/Dockerfile index c6e3440..f0206f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,16 @@ COPY package*.json ./ RUN npm ci 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 COPY --from=build /app/dist /usr/share/nginx/html 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 WORKDIR /app diff --git a/README.md b/README.md index 564619a..04650aa 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,10 @@ LocaleScope is a React + Vite app for researching local markets, saving business - `COOKIE_SECRET` - `GOOGLE_MAPS_SERVER_KEY` - Stripe env vars below if you want to test billing locally -3. Run the frontend: +3. Run the user frontend: `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. @@ -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 - [ ] 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 +- [ ] 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 the admin surface 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` @@ -56,7 +58,7 @@ Bootstrap mode is only needed when no active application admin exists. 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 +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` ## Stripe Billing Setup @@ -127,18 +129,26 @@ Safe mutation pilot: - `POSTGRES_USER` - `POSTGRES_PASSWORD` - `WEB_PORT` + - `ADMIN_WEB_PORT` - `APP_PORT` - `COOKIE_SECRET` - `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` 2. Create the shared Docker network if it does not exist: `docker network create locale-all || true` 3. Build and start the full stack: `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. -With the default `.env.example` values, the app is exposed on `http://localhost:3000` and the API on `http://localhost:4000`. +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 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 diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..b6a8099 --- /dev/null +++ b/admin.html @@ -0,0 +1,12 @@ + + + + + + Leads4less Admin + + +
+ + + diff --git a/docker-compose.yml b/docker-compose.yml index 07fc15c..a248045 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,22 @@ services: ports: - "${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: leads4less-db: diff --git a/nginx.admin.conf b/nginx.admin.conf new file mode 100644 index 0000000..ea1ba92 --- /dev/null +++ b/nginx.admin.conf @@ -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; + } +} diff --git a/package.json b/package.json index aa1208b..ecf9b76 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "scripts": { "dev": "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:worker": "tsx --tsconfig tsconfig.server.json server/src/worker.ts", "build": "vite build", + "build:admin": "vite build --config vite.admin.config.ts", "build:api": "tsc -p tsconfig.server.json", "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", "migrate": "tsx --tsconfig tsconfig.server.json db/scripts/migrate.ts", "import:postal": "tsx --tsconfig tsconfig.server.json db/scripts/import-postal-areas.ts", diff --git a/src/AdminPortal.tsx b/src/AdminPortal.tsx new file mode 100644 index 0000000..efa0443 --- /dev/null +++ b/src/AdminPortal.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(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(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 ; + } + + if (!hasApiConfig) { + return ( + +

Set VITE_API_BASE_URL and restart the admin frontend.

+
+ ); + } + + if (!user) { + return ( +
+ +
+
+ +
+
+

LocaleScope Admin

+

Sign in to access admin operations.

+
+
+ + {notice ? {notice} : null} + {error ? {error} : null} + +
+
+ Email + setEmail(event.target.value)} /> +
+
+ Password + setPassword(event.target.value)} + /> +
+ {isBootstrapRequired ? ( + <> +
+ Display name + setDisplayName(event.target.value)} + /> +
+
+ Bootstrap token + setBootstrapToken(event.target.value)} + /> +
+ + ) : null} + {isBootstrapRequired && !bootstrapLoading ? ( + Bootstrap is required because no active app admin exists. + ) : null} + +
+
+
+ ); + } + + if (!user.isAdmin) { + return ( + +
+

Your account does not have admin access.

+ {error ? {error} : null} + +
+
+ ); + } + + return ( +
+
+
+
+

LocaleScope

+

Admin Portal

+
+ +
+
+ +
+ ); +} + +function FullscreenCard({ title, children }: { title: string; children?: ReactNode }) { + return ( +
+ +
+
+ +
+

{title}

+
+ {children} +
+
+ ); +} diff --git a/src/App.tsx b/src/App.tsx index 42d817b..b0ac888 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,6 @@ import { } from 'lucide-react'; import { Layout, type AppTab } from './components/Layout'; import { AccountPage } from './components/AccountPage'; -import { AdminPage } from './components/AdminPage'; import { Dashboard } from './components/Dashboard'; import { MapView } from './components/MapView'; import { PricingCards } from './components/PricingCards'; @@ -109,12 +108,6 @@ export default function App() { const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true; - useEffect(() => { - if (activeTab === 'admin' && !user?.isAdmin) { - setActiveTab('account'); - } - }, [activeTab, user]); - useEffect(() => { let isMounted = true; @@ -372,7 +365,6 @@ export default function App() { onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)} /> )} - {activeTab === 'admin' && user.isAdmin && } ); diff --git a/src/admin-main.tsx b/src/admin-main.tsx new file mode 100644 index 0000000..20a1ea4 --- /dev/null +++ b/src/admin-main.tsx @@ -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( + + + , +); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index cd19d2b..f46250e 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,11 +1,11 @@ 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 { getUserAvatarUrl, getUserDisplayName } from '../lib/auth'; import { cn } from '../lib/cn'; import { Button } from './ui'; -export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account' | 'admin'; +export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account'; interface LayoutProps { user: SessionUser; @@ -25,7 +25,6 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La { id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard }, { id: 'map', name: 'Map View', icon: MapIcon }, { id: 'account', name: 'Account', icon: UserRound }, - ...(user.isAdmin ? ([{ id: 'admin', name: 'Admin', icon: ShieldCheck }] as const) : []), ] as const; const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0]; @@ -109,7 +108,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
{children}