From 9a6cdf22096222dd1458a3e9e0b337f8124cc6e4 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 18 Jul 2026 05:29:03 +0000 Subject: [PATCH 1/3] feat: add isolated web build for app.omnibioai.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `--mode web` Vite build target that ships the existing React UI as a browser SPA, kept strictly isolated from the Electron build: - src/ui/lib/web/{platform,webApi,session}.js — new, standalone web-only modules. None are imported by existing Electron-shared files; session.js (relative /auth/* paths instead of a direct http://:8001 fetch, which a real browser can't reach) is swapped in only under `--mode web` via a vite.config.js resolve.alias, so ../lib/session.js and every file that imports it are untouched. - vite.config.js — mode-gated: outDir only becomes dist/web and the alias only applies when mode === 'web'; the default (Electron-facing) build path is unchanged (verified: build:ui still outputs to dist/ using the original direct-IP session.js). - package.json web/web:build/web:preview now pass --mode web explicitly. - docker/nginx-web.conf, Dockerfile.web — static SPA serving for a new web-ui container (no backend proxying — that stays nginx-router.conf's job). - docker-compose.yml, docker/nginx-router.conf — new web-ui service + upstream. `location /` intentionally left pointed at workbench for now; repointing it to serve the SPA at the domain root is a separate, deliberate follow-up once nothing else depends on workbench being there. Verified both build modes independently: `npm run build:ui` (Electron) and `npm run web:build` (web) both succeed and produce distinct, correct output trees. --- .env.web | 7 ++ Dockerfile.web | 15 ++++ docker-compose.yml | 13 +++ docker/nginx-router.conf | 12 +++ docker/nginx-web.conf | 38 +++++++++ package.json | 6 +- src/ui/lib/web/platform.js | 16 ++++ src/ui/lib/web/session.js | 161 +++++++++++++++++++++++++++++++++++++ src/ui/lib/web/webApi.js | 66 +++++++++++++++ vite.config.js | 42 +++++++++- 10 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 .env.web create mode 100644 Dockerfile.web create mode 100644 docker/nginx-web.conf create mode 100644 src/ui/lib/web/platform.js create mode 100644 src/ui/lib/web/session.js create mode 100644 src/ui/lib/web/webApi.js diff --git a/.env.web b/.env.web new file mode 100644 index 0000000..3497662 --- /dev/null +++ b/.env.web @@ -0,0 +1,7 @@ +# Web-build-only env. Loaded automatically by `vite --mode web` / +# `vite build --mode web` (npm run web / web:build / web:preview) — +# ignored by the Electron scripts (dev, build:ui, build:linux, etc.), +# which never pass --mode. +VITE_WEB_MODE=true +VITE_VERSION=0.5.0-beta +VITE_APP_NAME=OmniBioAI Studio Web diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..872f222 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,15 @@ +# Builds the web SPA (npm run web:build → dist/web) and serves it via nginx. +# Independent of electron-builder.json / the electron-builder.json build: +# this image never touches electron/**, backend/**, or the Electron scripts. +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +COPY packages ./packages +RUN npm ci +COPY . . +RUN npm run web:build + +FROM nginx:alpine +COPY --from=builder /app/dist/web /usr/share/nginx/html +COPY docker/nginx-web.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/docker-compose.yml b/docker-compose.yml index ebf6283..6ac3e42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -842,6 +842,18 @@ services: condition: service_healthy restart: unless-stopped + # OmniBioAI Studio web SPA — served as static files behind nginx-router + # (see docker/nginx-router.conf's `web-ui` upstream / `location /`). + # Same in-repo build-context pattern as workbench/control-center use for + # their own Dockerfiles, just pointed at this repo instead of a sibling + # ${MACHINE_DIR}/... checkout, since Dockerfile.web lives here. + web-ui: + build: + context: . + dockerfile: Dockerfile.web + # image: ghcr.io/omnibioai/omnibioai-web:latest + restart: unless-stopped + nginx-router: image: nginx:latest ports: @@ -852,6 +864,7 @@ services: - ./docker/static:/etc/nginx/omni-static:ro depends_on: - workbench + - web-ui - lims - rag - api-gateway diff --git a/docker/nginx-router.conf b/docker/nginx-router.conf index 9fc8466..1ce4b18 100644 --- a/docker/nginx-router.conf +++ b/docker/nginx-router.conf @@ -38,6 +38,7 @@ upstream prometheus { server prometheus:9090; } upstream jupyter { server jupyter:8888; } upstream rstudio { server rstudio:8787; } upstream vscode { server vscode:8080; } +upstream web-ui { server web-ui:80; } server { listen 80; @@ -156,6 +157,17 @@ server { rewrite ^/_svc/auth(/.*)$ $1 break; proxy_pass http://auth; } + # Bare /auth/* — OAuth2 login/callback redirect targets registered with + # Google/GitHub/Microsoft are https://app.omnibioai.org/auth/{provider}/callback, + # i.e. this domain's root, not /_svc/auth. auth-service's own routes + # already live under /auth/*, so this is a straight passthrough with no + # rewrite (unlike /_svc/auth above, which strips its prefix). + # Kept alongside /_svc/auth rather than replacing it — other things may + # depend on that route. + location ^~ /auth/ { + limit_req zone=auth_limit burst=5 nodelay; + proxy_pass http://auth; + } location ^~ /_svc/policy { rewrite ^/_svc/policy/?(.*)$ /$1 break; proxy_pass http://policy; diff --git a/docker/nginx-web.conf b/docker/nginx-web.conf new file mode 100644 index 0000000..7f56c41 --- /dev/null +++ b/docker/nginx-web.conf @@ -0,0 +1,38 @@ +## +## Nginx for the omnibioai-web container ONLY — serves the built React SPA +## (dist/web) as static files with client-side-routing fallback. +## +## Deliberately does NOT proxy /auth, /_svc/*, or anything backend-related: +## that's docker/nginx-router.conf's job (the outer router in front of this +## container and every other service). Duplicating those routes here with +## different upstream names was the mistake in an earlier draft of this +## file — auth-service is reachable at auth-service:8001 and api-gateway at +## api-gateway:8080 per docker-compose.yml, never at the auth:8002 / +## api-gateway:8888 names/ports a prior version of this file guessed at. +## + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location = /_health { + return 200 '{"status":"ok","service":"omnibioai-web"}'; + add_header Content-Type application/json; + } + + # SPA client-side routing fallback. + location / { + try_files $uri $uri/ /index.html; + } + + # Long-cache hashed build assets; index.html itself falls through to the + # default (uncached) location above so deploys are picked up immediately. + location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/package.json b/package.json index 910ac1c..c4c2f7b 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "build:all": "cross-env OMNIBIOAI_DEV_MODE=false npm run build:ui && electron-builder --linux --mac --win", "build": "npm run build:linux", "dist": "npm run build", - "web": "vite", - "web:build": "vite build", - "web:preview": "vite preview" + "web": "vite --mode web", + "web:build": "vite build --mode web", + "web:preview": "vite preview --mode web --port 4173" }, "dependencies": { "@omnibioai/design-tokens": "file:./packages/omnibioai-design-tokens", diff --git a/src/ui/lib/web/platform.js b/src/ui/lib/web/platform.js new file mode 100644 index 0000000..c668bf8 --- /dev/null +++ b/src/ui/lib/web/platform.js @@ -0,0 +1,16 @@ +// Standalone platform-detection helper for web-specific code under +// src/ui/lib/web/ and any future web-only components. Not imported by any +// existing (Electron-shared) file, per the isolation requirement — nothing +// here can change Electron build behavior. +// +// Checks both bridges preload.js exposes (electron/preload.js): `window.api` +// (config/docker lifecycle) and `window.electronAPI` (license/grafana/links). +// A narrower check on just one of the two would misreport platform if a +// future preload change adds one bridge before the other. +export function isElectron() { + return typeof window !== "undefined" && !!(window.api || window.electronAPI); +} + +export function isWeb() { + return !isElectron(); +} diff --git a/src/ui/lib/web/session.js b/src/ui/lib/web/session.js new file mode 100644 index 0000000..19c5754 --- /dev/null +++ b/src/ui/lib/web/session.js @@ -0,0 +1,161 @@ +// Web-build session layer. Isolated copy of ../session.js — NOT imported by +// any Electron code path, and does not modify the original file. Swapped in +// for the "../lib/session" import only under `vite build --mode web` via the +// resolve.alias in vite.config.js, so the Electron build is unaffected. +// +// The only real difference from ../session.js: authUrl() below is relative +// (same-origin) instead of a direct http://:8001 fetch. A browser at +// https://app.omnibioai.org can't reach a private Docker-network host on a +// raw port — it has to go through nginx-router.conf's `location ^~ /auth/` +// passthrough (docker/nginx-router.conf), which forwards to auth-service +// with no path rewrite — auth-service's own routes already live under +// /auth/*, so every call site below (which already passes a path starting +// with "/auth/...") just needs that path used as-is, same-origin. + +const TOKEN_KEY = "omnibioai_access_token"; +const SESSION_EVENT = "omnibioai-session-changed"; + +export function authUrl(path) { + return path; +} + +let cachedUser = null; + +function notify() { + window.dispatchEvent(new CustomEvent(SESSION_EVENT)); +} + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setSession(accessToken) { + localStorage.setItem(TOKEN_KEY, accessToken); + cachedUser = null; + notify(); +} + +export function clearSession() { + localStorage.removeItem(TOKEN_KEY); + cachedUser = null; + notify(); +} + +export async function loginWithPassword(email, password) { + const res = await fetch(authUrl("/auth/login"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + throw new Error(res.status === 401 ? "Invalid email or password" : "Login failed"); + } + const data = await res.json(); + setSession(data.access_token); + return getCurrentUser({ force: true }); +} + +export async function getCurrentUser({ force = false } = {}) { + const token = getToken(); + if (!token) return null; + if (cachedUser && !force) return cachedUser; + + try { + const res = await fetch(authUrl("/auth/validate"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + const data = await res.json(); + if (!data.valid) { + clearSession(); + return null; + } + cachedUser = { + userId: data.user_id, + email: data.email, + roles: data.roles || [], + permissions: data.permissions || [], + }; + return cachedUser; + } catch (_) { + return null; + } +} + +export function getCurrentUserSync() { + return cachedUser; +} + +export function hasPermission(permission) { + return !!cachedUser?.permissions?.includes(permission); +} + +export function onSessionChange(callback) { + window.addEventListener(SESSION_EVENT, callback); + return () => window.removeEventListener(SESSION_EVENT, callback); +} + +// Always true here — this module only ever loads inside the web build. +export function isElectron() { + return false; +} + +const OAUTH_PROVIDERS = ["google", "github", "microsoft"]; + +export function getOAuthLoginUrl(provider) { + return authUrl(`/auth/${provider}/login`); +} + +export async function confirmOAuthLink(linkToken, password) { + const res = await fetch(authUrl("/auth/link/confirm"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ link_token: linkToken, password }), + }); + if (!res.ok) { + let detail = "Could not confirm the link"; + try { + detail = (await res.json())?.detail || detail; + } catch (_) { + // no JSON body + } + throw new Error(detail); + } + const data = await res.json(); + setSession(data.access_token); + return getCurrentUser({ force: true }); +} + +export function consumeOAuthRedirectParams() { + const url = new URL(window.location.href); + const params = url.searchParams; + + const status = params.get("status"); + if (!status) return null; + + let result; + if (status === "error") { + result = { type: "error", message: params.get("error") || "Sign-in failed" }; + } else if (status === "link_required") { + result = { + type: "link_required", + linkToken: params.get("link_token"), + provider: params.get("provider"), + email: params.get("email"), + }; + } else { + result = { type: "success" }; + if (params.has("access_token")) { + setSession(params.get("access_token")); + } + } + + window.history.replaceState({}, "", url.pathname); + + return result; +} + +export function oauthProviders() { + return OAUTH_PROVIDERS; +} diff --git a/src/ui/lib/web/webApi.js b/src/ui/lib/web/webApi.js new file mode 100644 index 0000000..aa6e934 --- /dev/null +++ b/src/ui/lib/web/webApi.js @@ -0,0 +1,66 @@ +// Web-safe stand-in for window.api (electron/preload.js), for future +// web-only components. Standalone under src/ui/lib/web/ — not wired into +// any existing shared page in this pass (Launch.jsx, Services.jsx, etc. +// already guard every window.api.* call with `?.`, so they degrade safely +// without this file; wiring them to call webApi instead would mean editing +// those shared files, which is out of scope for this isolation-only pass). +// +// Deliberately does NOT invent endpoints that don't exist in the backend. +// nginx-router.conf (docker/nginx-router.conf) only exposes one generic +// health route — `/_health` (router-level, not per-service) — and no +// `/api/config` or `/api/logs/:service` routes exist anywhere in this repo. +// Per-service health is already checked directly by Launch.jsx/Services.jsx +// via their own relative /_svc/* fetches; this file doesn't duplicate that. + +// Cloud/beta mode config — nothing to load, since there's no local Docker +// stack or data_dir to configure when connecting to an already-running +// backend. Mirrors the shape App.jsx expects from window.api.loadConfig(). +export async function loadConfig() { + return { mode: "beta", llm: {}, cloud: {}, hpc: {}, settings: {} }; +} + +// Best-effort, router-level only — does not indicate individual service +// health. See Launch.jsx's BETA_HEALTH_URLS / Services.jsx's HEALTH_URLS +// for the real per-service checks this app already does independently of +// window.api. +export async function checkHealth() { + try { + const res = await fetch("/_health", { signal: AbortSignal.timeout(3000) }); + return { ok: res.ok }; + } catch (_) { + return { ok: false }; + } +} + +// No local Docker to control in web/cloud mode — the backend is already +// running. Matches window.api.startDocker()/stopDocker()'s promise shape so +// callers don't need to branch on return value. +export async function startDocker() { + return { web: true, message: "Cloud services are already running — nothing to start." }; +} + +export async function stopDocker() { + return { web: true, message: "Cloud services are managed centrally — nothing to stop here." }; +} + +// No IPC log stream exists for a browser client. Calls back once with an +// informational line (matching window.api.streamLogs' callback shape) and +// returns a no-op unsubscribe — never opens a connection to an endpoint +// that doesn't exist. +export function streamLogs(callback) { + callback?.("Live log streaming isn't available in web mode."); + return () => {}; +} + +// Auto-update is an Electron/electron-updater concept (electron/updater.js) +// — not applicable to a browser tab. +export async function checkUpdate() { + return { available: false, web: true }; +} + +export async function getPlatform() { + return { + platform: "web", + version: import.meta.env.VITE_VERSION || "0.5.0-beta", + }; +} diff --git a/vite.config.js b/vite.config.js index 0d1d280..64ef6cb 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,12 +1,45 @@ +import { fileURLToPath, URL } from "node:url"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; const HOST = process.env.VITE_HOST || "192.168.86.234"; -export default defineConfig({ +// mode is only ever 'web' when invoked via `--mode web` (npm run web / +// web:build / web:preview). The default Electron-facing scripts (dev, +// build:ui, build:linux, etc.) never pass --mode, so `mode` is 'production' +// or 'development' there and every branch below is a no-op — outDir stays +// "dist" and the resolve.alias swap never applies. Electron build is +// untouched. +export default defineConfig(({ mode }) => { + const isWeb = mode === "web"; + + return { plugins: [react()], + resolve: { + alias: isWeb + ? [ + // Swaps every "…/lib/session" import to the isolated web-only + // implementation (src/ui/lib/web/session.js) — same-origin /auth/* + // paths instead of a direct http://:8001 fetch, which a + // real browser at app.omnibioai.org can't reach. Matches by + // resolved-path suffix so it applies regardless of which file + // does the importing, without editing any of those files. + // + // The regex must match the WHOLE specifier (^...$), not just a + // trailing chunk of it — vite's builtin alias plugin substitutes + // only the matched substring in place, so a suffix-only match like + // /\/lib\/session$/ leaves the leading "./" or "../" in front of + // the absolute replacement path and produces a bogus specifier + // (e.g. "./lib/session" -> "./" + "/abs/path" -> "./home/..."). + { + find: /^\.\.?\/(?:.*\/)?lib\/session(\.js)?$/, + replacement: fileURLToPath(new URL("./src/ui/lib/web/session.js", import.meta.url)), + }, + ] + : [], + }, build: { - outDir: "dist", + outDir: isWeb ? "dist/web" : "dist", emptyOutDir: true, sourcemap: false }, @@ -15,6 +48,10 @@ export default defineConfig({ port: 5174, strictPort: true, proxy: { + // Web-mode-only: session.js (web version) hits relative /auth/* paths; + // this mirrors nginx-router.conf's `location ^~ /auth/` passthrough so + // `npm run web` works against a real backend during local dev. + ...(isWeb ? { "/auth": { target: `https://app.omnibioai.org`, changeOrigin: true, secure: true } } : {}), "/_svc/gateway": { target: `http://${HOST}:8080`, changeOrigin: true, rewrite: () => "/health" }, "/_svc/auth": { target: `http://${HOST}:8001`, changeOrigin: true, rewrite: () => "/health" }, "/_svc/policy": { target: `http://${HOST}:8002`, changeOrigin: true, rewrite: () => "/docs" }, @@ -61,4 +98,5 @@ export default defineConfig({ "/_svc/grafana": { target: "http://localhost:3000", changeOrigin: true, secure: false, rewrite: (p) => p.replace(/^\/_svc\/grafana/, "") || "/" }, } } + }; }); From 8b39a1282f164507cc053ac7f1410b2e9ee5160d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 18 Jul 2026 05:31:20 +0000 Subject: [PATCH 2/3] feat(web): add Login, OAuthLinkConfirm, session and App web support --- src/ui/App.jsx | 39 ++++++++++- src/ui/components/Login.jsx | 90 ++++++++++++++++++++++++++ src/ui/components/OAuthLinkConfirm.jsx | 70 ++++++++++++++++++++ src/ui/lib/session.js | 75 +++++++++++++++++++++ src/ui/pages/RoleManagement.jsx | 71 +------------------- 5 files changed, 275 insertions(+), 70 deletions(-) create mode 100644 src/ui/components/Login.jsx create mode 100644 src/ui/components/OAuthLinkConfirm.jsx diff --git a/src/ui/App.jsx b/src/ui/App.jsx index ac0a173..b69ebae 100644 --- a/src/ui/App.jsx +++ b/src/ui/App.jsx @@ -17,8 +17,9 @@ import ServiceViewer from "./pages/ServiceViewer"; import Videos from "./pages/Videos"; import IdeServices from "./pages/IdeServices"; import RoleManagement from "./pages/RoleManagement"; +import OAuthLinkConfirm from "./components/OAuthLinkConfirm"; import { GrafanaViewer } from "./components/GrafanaViewer"; -import { getCurrentUser, onSessionChange } from "./lib/session"; +import { getCurrentUser, onSessionChange, consumeOAuthRedirectParams } from "./lib/session"; const BASE_NAV = [ { section: "Setup", items: [ @@ -69,6 +70,7 @@ export default function App() { }); const [service, setService] = useState(null); // { url, label } when viewing a service const [currentUser, setCurrentUser] = useState(null); // decoded JWT claims, or null if signed out + const [oauthNotice, setOauthNotice] = useState(null); // result of an OAuth redirect: link_required | error // ─── Load saved config + first-run detection ─────────── useEffect(() => { @@ -122,6 +124,16 @@ export default function App() { return () => { mounted = false; unsubscribe(); }; }, []); + // ─── Consume an OAuth provider redirect back into the app, once ── + // A successful redirect already called setSession() internally (see + // consumeOAuthRedirectParams), which the listener above picks up; this only + // needs to handle what setSession alone can't: prompting for account-link + // confirmation, or surfacing a failed-sign-in message. + useEffect(() => { + const result = consumeOAuthRedirectParams(); + if (result && result.type !== "success") setOauthNotice(result); + }, []); + // Keep the nav item visible while signed out (or still loading) so there's // an entry point to sign in — only hide it once we positively know the // signed-in user lacks manage_roles. @@ -359,6 +371,31 @@ export default function App() { )} + + {oauthNotice?.type === "link_required" && ( + setOauthNotice(null)} + onCancel={() => setOauthNotice(null)} + /> + )} + + {oauthNotice?.type === "error" && ( +
+
Sign-in failed: {oauthNotice.message}
+
setOauthNotice(null)}>✕
+
+ )} + ); diff --git a/src/ui/components/Login.jsx b/src/ui/components/Login.jsx new file mode 100644 index 0000000..fc76a8b --- /dev/null +++ b/src/ui/components/Login.jsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Card, Button, Badge, Input } from "@omnibioai/ui"; +import { loginWithPassword, isElectron, getOAuthLoginUrl, oauthProviders } from "../lib/session"; + +const PROVIDER_LABELS = { google: "Google", github: "GitHub", microsoft: "Microsoft" }; + +export default function Login({ title = "Sign in required", description }) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async (e) => { + e.preventDefault(); + setBusy(true); + setError(""); + try { + await loginWithPassword(email, password); + // Success dispatches omnibioai-session-changed; listeners (e.g. App.jsx) + // pick up the new currentUser and re-render past this screen. + } catch (err) { + setError(err.message || "Login failed"); + } finally { + setBusy(false); + } + }; + + const startOAuth = (provider) => { + window.location.href = getOAuthLoginUrl(provider); + }; + + return ( +
+
+ + {description && ( +
+ {description} +
+ )} +
+
+ setEmail(e.target.value)} placeholder="you@omnibioai" /> +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + style={{ + width: "100%", boxSizing: "border-box", + background: "var(--bg2)", border: "1px solid var(--border2)", + borderRadius: "var(--radius-sm)", padding: "7px 10px", + fontSize: "var(--font-size-sm)", fontFamily: "var(--mono)", + color: "var(--text)", outline: "none", + }} + /> +
+ {error &&
{error}
} + +
+ + {!isElectron() && ( +
+
+
+ or +
+
+
+ {oauthProviders().map((provider) => ( + + ))} +
+
+ )} + +
+
+ ); +} diff --git a/src/ui/components/OAuthLinkConfirm.jsx b/src/ui/components/OAuthLinkConfirm.jsx new file mode 100644 index 0000000..900af48 --- /dev/null +++ b/src/ui/components/OAuthLinkConfirm.jsx @@ -0,0 +1,70 @@ +import React, { useState } from "react"; +import { Card, Button, Badge } from "@omnibioai/ui"; +import { confirmOAuthLink } from "../lib/session"; + +const PROVIDER_LABELS = { google: "Google", github: "GitHub", microsoft: "Microsoft" }; + +export default function OAuthLinkConfirm({ linkToken, provider, email, onDone, onCancel }) { + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async (e) => { + e.preventDefault(); + setBusy(true); + setError(""); + try { + await confirmOAuthLink(linkToken, password); + onDone(); + } catch (err) { + setError(err.message || "Could not confirm the link"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ +
+ An account already exists for {email}. + Enter its password to link your {PROVIDER_LABELS[provider] || provider} sign-in to it. +
+
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + autoFocus + style={{ + width: "100%", boxSizing: "border-box", + background: "var(--bg2)", border: "1px solid var(--border2)", + borderRadius: "var(--radius-sm)", padding: "7px 10px", + fontSize: "var(--font-size-sm)", fontFamily: "var(--mono)", + color: "var(--text)", outline: "none", + }} + /> +
+ {error &&
{error}
} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/ui/lib/session.js b/src/ui/lib/session.js index f847916..ae4aa1f 100644 --- a/src/ui/lib/session.js +++ b/src/ui/lib/session.js @@ -98,3 +98,78 @@ export function onSessionChange(callback) { window.addEventListener(SESSION_EVENT, callback); return () => window.removeEventListener(SESSION_EVENT, callback); } + +// ── OAuth2 SSO ──────────────────────────────────────────────────────────────── +// Web build only — the Electron app keeps email/password per its landing +// page copy. Electron also disables webSecurity, so a provider's redirect +// back to a browser origin wouldn't land inside the app window anyway. +export function isElectron() { + return !!(window.electronAPI || window.api); +} + +const OAUTH_PROVIDERS = ["google", "github", "microsoft"]; + +export function getOAuthLoginUrl(provider) { + return authUrl(`/auth/${provider}/login`); +} + +export async function confirmOAuthLink(linkToken, password) { + const res = await fetch(authUrl("/auth/link/confirm"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ link_token: linkToken, password }), + }); + if (!res.ok) { + let detail = "Could not confirm the link"; + try { + detail = (await res.json())?.detail || detail; + } catch (_) { + // no JSON body + } + throw new Error(detail); + } + const data = await res.json(); + setSession(data.access_token); + return getCurrentUser({ force: true }); +} + +// Reads the query params the auth-service's GET /auth/{provider}/callback +// redirects back with — status=ok|link_required|error, see omnibioai-auth's +// routes_oauth.py — applies them, and strips them from the URL so a page +// refresh doesn't reprocess a stale token or link_token. Returns null if +// there was nothing OAuth-related to process. Path-agnostic (keys off query +// params, not pathname) since it's not yet settled which URL in production +// the provider redirect actually lands the browser on. +export function consumeOAuthRedirectParams() { + const url = new URL(window.location.href); + const params = url.searchParams; + + const status = params.get("status"); + if (!status) return null; + + let result; + if (status === "error") { + result = { type: "error", message: params.get("error") || "Sign-in failed" }; + } else if (status === "link_required") { + result = { + type: "link_required", + linkToken: params.get("link_token"), + provider: params.get("provider"), + email: params.get("email"), + }; + } else { + result = { type: "success" }; + if (params.has("access_token")) { + setSession(params.get("access_token")); + } + } + + // Drop the query string so refresh/back doesn't replay a one-time token. + window.history.replaceState({}, "", url.pathname); + + return result; +} + +export function oauthProviders() { + return OAUTH_PROVIDERS; +} diff --git a/src/ui/pages/RoleManagement.jsx b/src/ui/pages/RoleManagement.jsx index 6301445..1c33e95 100644 --- a/src/ui/pages/RoleManagement.jsx +++ b/src/ui/pages/RoleManagement.jsx @@ -1,14 +1,14 @@ import React, { useCallback, useEffect, useState } from "react"; import { Card, Button, Badge, Table, Input, Tabs } from "@omnibioai/ui"; import { Panel, PanelHeader, PanelBody, FormRow } from "../components/UI"; -import { loginWithPassword } from "../lib/session"; +import Login from "../components/Login"; import * as rolesApi from "../lib/rolesApi"; const MANAGE_ROLES = "manage_roles"; export default function RoleManagement({ currentUser }) { if (!currentUser) { - return ; + return ; } if (!currentUser.permissions?.includes(MANAGE_ROLES)) { return ; @@ -16,73 +16,6 @@ export default function RoleManagement({ currentUser }) { return ; } -// ── Sign-in gate ────────────────────────────────────────────────────────────── -// This is a stopgap: the wider Studio app has no login flow yet. Session 3/4 -// (OAuth2 SSO) should replace this with a real login screen; this component -// only needs `session.setSession()` to have been called by then. - -function LoginGate() { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [busy, setBusy] = useState(false); - - const submit = async (e) => { - e.preventDefault(); - setBusy(true); - setError(""); - try { - await loginWithPassword(email, password); - // A successful login dispatches omnibioai-session-changed; App.jsx - // listens for that and re-renders this page with currentUser set. - } catch (err) { - setError(err.message || "Login failed"); - } finally { - setBusy(false); - } - }; - - return ( -
-
- -
- Role management requires an authenticated OmniBioAI account. -
-
-
- setEmail(e.target.value)} placeholder="you@omnibioai" /> -
-
- - setPassword(e.target.value)} - placeholder="••••••••" - style={{ - width: "100%", boxSizing: "border-box", - background: "var(--bg2)", border: "1px solid var(--border2)", - borderRadius: "var(--radius-sm)", padding: "7px 10px", - fontSize: "var(--font-size-sm)", fontFamily: "var(--mono)", - color: "var(--text)", outline: "none", - }} - /> -
- {error &&
{error}
} - -
-
-
-
- ); -} - function AccessDenied({ email }) { return (
From 3485c966f6329b6e1774a01ce98581fc15e93088 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 18 Jul 2026 05:57:35 +0000 Subject: [PATCH 3/3] feat(web): wire web-ui to nginx root, fix Dockerfile.web, add CI build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker/nginx-router.conf — location / now proxies to web-ui instead of workbench, making the SPA visible at the domain root. Only that one location block changed; /_svc/workbench (used by Workbench.jsx, Services.jsx, Launch.jsx) is untouched — a naive global replace of every `proxy_pass http://workbench;` would have broken that route too, since the same string appears in both places. - Dockerfile.web — fixes two build failures found while actually testing `docker build`, not just `npm run web:build` on the host: 1. @omnibioai/ui is consumed as prebuilt dist/ output, but .dockerignore excludes **/dist from the build context, so the copied package had none. Added an explicit build step for it before the app build. 2. packages/omnibioai-ui/package-lock.json is out of sync with its own package.json (missing @omnibioai/design-tokens) — `npm ci` refuses a mismatched lockfile by design. Used `npm install` for that one sub-package instead of hand-patching a vendored lockfile from a separately-maintained package. Verified: `docker build -f Dockerfile.web .` succeeds, the container runs, serves the real index.html at /, responds on /_health, and falls through unknown paths to index.html (200) via SPA routing. - .github/workflows/build-web.yml — builds and pushes ghcr.io/omnibioai/omnibioai-web on push to main. Adds an explicit `permissions: packages: write` block; without it GITHUB_TOKEN can't push to ghcr.io and the job 403s. - docker-compose.release.yml (not docker-compose.yml) gets the image-only web-ui service. This repo's existing convention: docker-compose.yml builds every service from source (workbench/control-center both keep build: active with # image: commented out); docker-compose.release.yml is the image-only production compose (workbench there has image: with no build: block at all). web-ui follows that same split — its build-from-source version stays in docker-compose.yml from the prior commit, unchanged. --- .github/workflows/build-web.yml | 31 +++++++++++++++++++++++++++++++ Dockerfile.web | 15 +++++++++++++++ docker-compose.release.yml | 9 +++++++++ docker/nginx-router.conf | 2 +- 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-web.yml diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml new file mode 100644 index 0000000..5c23793 --- /dev/null +++ b/.github/workflows/build-web.yml @@ -0,0 +1,31 @@ +name: Build Web UI + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'packages/**' + - 'Dockerfile.web' + - 'docker/nginx-web.conf' + +jobs: + build: + runs-on: ubuntu-latest + # Required for GITHUB_TOKEN to push to ghcr.io — without this the + # `docker push` step below fails with 403, since the default token + # permissions don't include package write access. + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Build and push + run: | + docker build -f Dockerfile.web -t ghcr.io/omnibioai/omnibioai-web:latest -t ghcr.io/omnibioai/omnibioai-web:${{ github.sha }} . + docker push ghcr.io/omnibioai/omnibioai-web:latest + docker push ghcr.io/omnibioai/omnibioai-web:${{ github.sha }} diff --git a/Dockerfile.web b/Dockerfile.web index 872f222..d88bfc8 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -6,6 +6,21 @@ WORKDIR /app COPY package*.json ./ COPY packages ./packages RUN npm ci +# @omnibioai/ui is a file: dependency consumed as prebuilt dist/ output +# (its package.json main/module/css all point into dist/). .dockerignore +# excludes **/dist from the build context, so unlike a host checkout where +# dist/ was already built once, the copied packages/omnibioai-ui here has +# none — it has to be built explicitly or `npm run web:build` fails to +# resolve "@omnibioai/ui/dist/index.css" from src/ui/main.jsx. +# @omnibioai/design-tokens needs no such step — it ships plain source files +# (tokens.css/.js/.ts) directly, per its package.json. +# npm ci here fails: packages/omnibioai-ui/package-lock.json is out of sync +# with its own package.json (missing the @omnibioai/design-tokens entry) — +# a pre-existing issue in that package (it has its own repo, see its +# package.json "repository" field), not something to hand-patch in a +# vendored lockfile from here. `npm install` reconciles it instead of +# failing strict the way `ci` does. +RUN cd packages/omnibioai-ui && npm install && npm run build COPY . . RUN npm run web:build diff --git a/docker-compose.release.yml b/docker-compose.release.yml index 8de3218..0891175 100644 --- a/docker-compose.release.yml +++ b/docker-compose.release.yml @@ -614,6 +614,14 @@ services: - prometheus restart: unless-stopped + # --------------------------------------------------------------------------- + # Web SPA + # --------------------------------------------------------------------------- + + web-ui: + image: ghcr.io/omnibioai/omnibioai-web:latest + restart: unless-stopped + # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- @@ -626,6 +634,7 @@ services: - ./docker/nginx-router.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - workbench + - web-ui - lims - rag - api-gateway diff --git a/docker/nginx-router.conf b/docker/nginx-router.conf index 1ce4b18..1e54be4 100644 --- a/docker/nginx-router.conf +++ b/docker/nginx-router.conf @@ -200,7 +200,7 @@ server { proxy_set_header Connection "upgrade"; } - location / { proxy_pass http://workbench; } + location / { proxy_pass http://web-ui; } # Internal subrequest — strips body, forwards Authorization header only location = /internal/auth/verify {