From 10771a8ffcce75f7c5914c22d528d54cb1ade655 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 24 May 2026 19:01:51 +0100 Subject: [PATCH] feat(frontend)!: drop SetupWizardPage, backend URL via build-time VITE_* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend no longer has any wizard flow. Backend URL is baked into the bundle at build time via VITE_HF_BACKEND_BASE_URL (forwarded as a Dockerfile ARG from compose). - src/App.tsx: drop SetupWizardPage import + appState='setup' fallback + HF_WIZARD_PORT-via-localStorage probe. getApiBase() now reads import.meta.env.VITE_HF_BACKEND_BASE_URL with localStorage as an escape hatch for dev. When /config/status reports no admin yet, show a card prompting the operator to run `docker exec hf_backend hf-cli admin create-user ...`. - src/pages/SetupWizardPage.tsx: deleted (~250 lines) - src/index.css: drop .setup-wizard + .setup-* styles (~36 lines) - src/vite-env.d.ts: add VITE_HF_BACKEND_BASE_URL to ImportMetaEnv - Dockerfile: ARG VITE_HF_BACKEND_BASE_URL → ENV → npm run build Build the prod image with: docker build --build-arg VITE_HF_BACKEND_BASE_URL=https://hf-api.hangman-lab.top \ -t git.hangman-lab.top/zhi/harborforge-frontend:latest . Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 8 + src/App.tsx | 93 ++++++----- src/index.css | 39 +---- src/pages/SetupWizardPage.tsx | 295 ---------------------------------- src/vite-env.d.ts | 8 + 5 files changed, 71 insertions(+), 372 deletions(-) delete mode 100644 src/pages/SetupWizardPage.tsx diff --git a/Dockerfile b/Dockerfile index 0b10370..afdb473 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,14 @@ # Build stage FROM node:20-alpine AS build WORKDIR /app + +# Build-time backend URL — Vite inlines this into the bundle. Passed as +# `--build-arg VITE_HF_BACKEND_BASE_URL=https://hf-api.example.com` in +# the compose file. Without it the bundle calls relative paths (only +# works in dev with the Vite proxy). +ARG VITE_HF_BACKEND_BASE_URL="" +ENV VITE_HF_BACKEND_BASE_URL=${VITE_HF_BACKEND_BASE_URL} + COPY package.json package-lock.json* ./ RUN npm install COPY . . diff --git a/src/App.tsx b/src/App.tsx index 0db4477..70e4717 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,6 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { useAuth } from '@/hooks/useAuth' import Sidebar from '@/components/Sidebar' import LoginPage from '@/pages/LoginPage' -import SetupWizardPage from '@/pages/SetupWizardPage' import DashboardPage from '@/pages/DashboardPage' import TasksPage from '@/pages/TasksPage' import TaskDetailPage from '@/pages/TaskDetailPage' @@ -24,19 +23,23 @@ import OidcCallbackPage from '@/pages/OidcCallbackPage' import OidcSettingsPage from '@/pages/OidcSettingsPage' import axios from 'axios' -const getStoredWizardPort = (): number | null => { - const stored = Number(localStorage.getItem('HF_WIZARD_PORT')) - return stored && stored > 0 ? stored : null +// Backend URL is baked in at build time via VITE_HF_BACKEND_BASE_URL (the +// docker-compose hf-frontend service passes it as a build ARG). Falling +// back to a same-origin call only makes sense in dev with the Vite proxy. +// localStorage override is kept as an escape hatch for one-off pointing +// (e.g. dev pointing the prod build at a local backend). +const getApiBase = (): string => { + const ls = localStorage.getItem('HF_BACKEND_BASE_URL') + if (ls) return ls + const baked = import.meta.env.VITE_HF_BACKEND_BASE_URL + return baked || '' } -const getApiBase = () => { - return localStorage.getItem('HF_BACKEND_BASE_URL') ?? undefined -} - -type AppState = 'checking' | 'setup' | 'ready' +type AppState = 'checking' | 'no-admin' | 'ready' export default function App() { const [appState, setAppState] = useState('checking') + const [errorMessage, setErrorMessage] = useState('') const { user, loading, login, loginWithToken, logout } = useAuth() useEffect(() => { @@ -44,49 +47,61 @@ export default function App() { }, []) const checkInitialized = async () => { - // First try the backend /config/status endpoint (reads from config volume directly) try { const res = await axios.get(`${getApiBase()}/config/status`, { timeout: 5000 }) const cfg = res.data || {} - if (cfg.backend_url) { - localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url) - } if (cfg.initialized === true) { setAppState('ready') return } - } catch { - // Backend unreachable — fall through to wizard check + setAppState('no-admin') + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + setErrorMessage(`Backend unreachable at ${getApiBase() || ''} — ${msg}`) + setAppState('no-admin') } - - // Fallback: if a wizard port was previously saved during setup, try it directly - const storedPort = getStoredWizardPort() - if (storedPort) { - try { - const res = await axios.get(`http://127.0.0.1:${storedPort}/api/v1/config/harborforge.json`, { - timeout: 5000, - }) - const cfg = res.data || {} - if (cfg.backend_url) { - localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url) - } - if (cfg.initialized === true) { - setAppState('ready') - return - } - } catch { - // ignore — fall through to setup - } - } - setAppState('setup') } if (appState === 'checking') { - return
Checking configuration status...
+ return
Checking deployment status…
} - if (appState === 'setup') { - return + if (appState === 'no-admin') { + return ( +
+
+

⚓ HarborForge

+ {errorMessage ? ( + <> +

Cannot reach the backend.

+
{errorMessage}
+

+ Set VITE_HF_BACKEND_BASE_URL at build time + (e.g. https://hf-api.example.com) in the + frontend container's compose entry. +

+ + ) : ( + <> +

+ No admin user found. Bootstrap the deployment by running, on the host: +

+
+{`docker exec hf-backend hf-cli admin create-user \\
+  --email you@example.com \\
+  --password '...' \\
+  # ...or in OIDC_ONLY mode:
+  --oidc-issuer https://login.example.com/realms/your-realm \\
+  --oidc-subject `}
+              
+ + + )} +
+
+ ) } if (loading) return
Loading...
diff --git a/src/index.css b/src/index.css index 6646833..23cc354 100644 --- a/src/index.css +++ b/src/index.css @@ -180,7 +180,7 @@ input, textarea, select, button { font-family: inherit; } .sidebar-footer button:hover { border-color: var(--accent); color: var(--accent); } /* ---- Login -------------------------------------------------------------- */ -.login-page, .setup-wizard { +.login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; } @@ -469,43 +469,6 @@ dd { font-size: .92rem; font-family: 'JetBrains Mono', monospace; } .empty::after { content: ' —'; color: var(--accent); } .text-dim { color: var(--text-dim); font-size: .82rem; } -/* ---- Setup Wizard ------------------------------------------------------- */ -.setup-container { - position: relative; background: var(--bg-card); border: var(--hair); - border-radius: 6px; padding: 44px; max-width: 620px; width: 100%; - box-shadow: 0 40px 80px -30px rgba(0,0,0,.8); - animation: deck-in .55s cubic-bezier(.16,1,.3,1) both; -} -.setup-container::before { content: ''; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--ember); border-radius: 6px 6px 0 0; } -.setup-header { text-align: center; margin-bottom: 34px; } -.setup-header h1 { font-size: 1.6rem; margin-bottom: 22px; letter-spacing: .1em; } -.setup-steps { display: flex; justify-content: center; gap: 8px; flex-wrap: wrap; } -.setup-step { - font-size: .68rem; color: var(--text-dim); padding: 5px 12px; border-radius: 2px; - border: var(--hair); text-transform: uppercase; letter-spacing: .1em; - font-family: 'JetBrains Mono', monospace; -} -.setup-step.active { color: var(--accent); border-color: var(--accent); background: var(--ember-soft); } -.setup-step.done { color: var(--success); border-color: var(--success); } -.setup-step-content { animation: fadeIn .25s ease; } -.setup-step-content h2 { margin-bottom: 10px; font-size: 1.3rem; } -.setup-form { margin: 22px 0; } -.setup-nav { display: flex; justify-content: space-between; margin-top: 28px; } -.setup-nav button:disabled { opacity: .5; cursor: default; } -.setup-error { - background: rgba(226,85,60,.12); border: 1px solid var(--danger); color: var(--danger); - padding: 13px 16px; border-radius: var(--radius); margin-bottom: 18px; - font-size: .85rem; white-space: pre-line; font-family: 'JetBrains Mono', monospace; -} -.setup-info { background: rgba(86,198,214,.07); border: 1px solid rgba(86,198,214,.25); padding: 18px; border-radius: var(--radius); margin: 18px 0; } -.setup-info code { - display: block; background: var(--bg-sink); padding: 10px 13px; border-radius: 2px; - margin-top: 10px; font-size: .82rem; color: var(--steel); word-break: break-all; -} -.setup-hint { color: var(--warning); font-size: .82rem; margin-top: 8px; } -.setup-done { text-align: center; } -.setup-done h2 { color: var(--success); margin-bottom: 14px; } - /* ---- Monitor ------------------------------------------------------------ */ .monitor-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 18px; margin-top: 14px; } .monitor-card { diff --git a/src/pages/SetupWizardPage.tsx b/src/pages/SetupWizardPage.tsx deleted file mode 100644 index 0e7af4a..0000000 --- a/src/pages/SetupWizardPage.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { useState } from 'react' -import axios from 'axios' -import { getRuntimeOidcOnly, getLogoUrl } from '@/runtime' - -interface Props { - initialWizardPort: number | null - onComplete: () => void -} - -interface SetupForm { - admin_username: string - admin_password: string - admin_email: string - admin_full_name: string - backend_base_url: string - project_name: string - project_description: string - oidc_enabled: boolean - oidc_issuer: string - oidc_client_id: string - oidc_client_secret: string - oidc_redirect_uri: string - oidc_scopes: string - oidc_post_login_redirect: string - oidc_admin_role: string -} - -const oidcOnly = getRuntimeOidcOnly() === true -const STEPS = ['Wizard', 'Admin', 'OIDC', 'Backend', 'Finish'] - -export default function SetupWizardPage({ initialWizardPort, onComplete }: Props) { - const [step, setStep] = useState(0) - const [error, setError] = useState('') - const [saving, setSaving] = useState(false) - const [connecting, setConnecting] = useState(false) - const [wizardPortInput, setWizardPortInput] = useState( - initialWizardPort ? String(initialWizardPort) : '' - ) - const [wizardBase, setWizardBase] = useState('') - const [form, setForm] = useState({ - admin_username: 'admin', - admin_password: '', - admin_email: '', - admin_full_name: 'Admin', - backend_base_url: '', - project_name: '', - project_description: '', - oidc_enabled: oidcOnly, - oidc_issuer: '', - oidc_client_id: '', - oidc_client_secret: '', - oidc_redirect_uri: '', - oidc_scopes: 'openid email profile', - oidc_post_login_redirect: '', - oidc_admin_role: 'admin', - }) - - const set = (key: keyof SetupForm, value: string | number | boolean) => - setForm((f) => ({ ...f, [key]: value })) - - const checkWizard = async () => { - setError('') - const port = Number(wizardPortInput) - if (!port || port <= 0 || port > 65535) { - setError('Please enter a valid wizard port (1-65535).') - return - } - const base = `http://127.0.0.1:${port}` - setConnecting(true) - try { - await axios.get(`${base}/health`, { timeout: 5000 }) - setWizardBase(base) - localStorage.setItem('HF_WIZARD_PORT', String(port)) - setStep(1) - } catch { - setError(`Unable to connect to AbstractWizard at ${base}.\nMake sure the SSH tunnel is up:\nssh -L ${port}:127.0.0.1:${port} user@server`) - } finally { - setConnecting(false) - } - } - - const validateOidc = (): string => { - if (!oidcOnly && !form.oidc_enabled) return '' - if (!form.oidc_issuer.trim()) return 'OIDC issuer is required' - if (!form.oidc_client_id.trim()) return 'OIDC client ID is required' - if (!form.oidc_client_secret.trim()) return 'OIDC client secret is required' - if (!form.oidc_redirect_uri.trim()) return 'OIDC redirect/callback URL is required' - if (oidcOnly && !form.oidc_admin_role.trim()) { - return 'In OIDC-only mode the admin role is required so the admin can bootstrap' - } - return '' - } - - const saveConfig = async () => { - setError('') - setSaving(true) - try { - const includeOidc = oidcOnly || form.oidc_enabled - const config: Record = { - initialized: true, - admin: { - username: form.admin_username, - password: form.admin_password, - email: form.admin_email, - full_name: form.admin_full_name, - }, - backend_url: form.backend_base_url || undefined, - } - if (includeOidc) { - config.oidc = { - enabled: true, - issuer: form.oidc_issuer.trim(), - client_id: form.oidc_client_id.trim(), - client_secret: form.oidc_client_secret, - redirect_uri: form.oidc_redirect_uri.trim(), - scopes: form.oidc_scopes.trim() || 'openid email profile', - post_login_redirect: form.oidc_post_login_redirect.trim() || undefined, - admin_role: form.oidc_admin_role.trim() || 'admin', - } - } - - await axios.put(`${wizardBase}/api/v1/config/harborforge.json`, config, { - headers: { 'Content-Type': 'application/json' }, - timeout: 5000, - }) - - if (form.backend_base_url) { - localStorage.setItem('HF_BACKEND_BASE_URL', form.backend_base_url) - } - - setStep(4) - } catch (err: any) { - setError(`Failed to save configuration: ${err.message}`) - } finally { - setSaving(false) - } - } - - return ( -
-
-
-

HarborForge Setup Wizard

-
- {STEPS.map((s, i) => ( - - {i < step ? '✓' : i + 1}. {s} - - ))} -
-
- - {error &&
{error}
} - - {/* Step 0: Wizard connection */} - {step === 0 && ( -
-

Connect to AbstractWizard

-

Enter the local port that forwards to AbstractWizard, then test the connection.

-
-

⚠️ AbstractWizard is reached over an SSH tunnel. Forward the port first:

- ssh -L <wizard_port>:127.0.0.1:<wizard_port> user@your-server -
-
- -
-
- -
-
- )} - - {/* Step 1: Admin */} - {step === 1 && ( -
-

Admin account

-

Create the first admin user

-
- - - - -
- {oidcOnly && ( -

OIDC-only deployment: this admin will sign in via OIDC; the password is kept only as a fallback identity record.

- )} -
- - -
-
- )} - - {/* Step 2: OIDC */} - {step === 2 && ( -
-

OIDC {oidcOnly ? '(required)' : '(optional)'}

-

- {oidcOnly - ? 'This deployment runs in OIDC-only mode — configure the identity provider now (the admin cannot reach this page again without it).' - : 'Optionally configure single sign-on. You can also do this later from the admin OIDC settings page.'} -

- {!oidcOnly && ( - - )} - {(oidcOnly || form.oidc_enabled) && ( -
- - - - - - - -

Register the Redirect / Callback URL above at your identity provider.

- {oidcOnly && ( -

- OIDC-only: before any admin is linked, the first IdP user whose token carries the - role above auto-connects to the HarborForge admin account. It then disables itself. -

- )} -
- )} -
- - -
-
- )} - - {/* Step 3: Backend */} - {step === 3 && ( -
-

Backend URL

-

Configure the HarborForge backend API URL (leave blank to use the frontend default).

-
- -
-
- - -
-
- )} - - {/* Step 4: Done */} - {step === 4 && ( -
-
-

✅ Setup complete!

-

Configuration saved to AbstractWizard.

-
-

Restart services on the server:

- docker compose restart -

After the backend starts, refresh this page to go to login.

-

Admin account: {form.admin_username}

-
- -
-
- )} -
-
- ) -} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 7019602..fe66423 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,14 @@ interface ImportMetaEnv { readonly VITE_API_BASE: string + /** + * Backend base URL baked in at build time (e.g. + * https://hf-api.example.com). Frontend uses this for all API calls. + * Passed to the Dockerfile as an ARG and forwarded to `npm run build`. + * Empty string falls back to same-origin (only useful in dev with the + * Vite proxy). + */ + readonly VITE_HF_BACKEND_BASE_URL: string } interface ImportMeta {