diff --git a/plugins/web-ui/server/index.ts b/plugins/web-ui/server/index.ts index 9a1340413..343194a72 100644 --- a/plugins/web-ui/server/index.ts +++ b/plugins/web-ui/server/index.ts @@ -33,6 +33,8 @@ const PUBLIC_URL = (process.env.WEB_UI_PUBLIC_URL ?? `http://localhost:${PORT}`) const WEB_UI_DEV = process.env.WEB_UI_DEV === "1"; const ALLOW_UNSIGNED_TEST_IDENTITY = process.env.NODE_ENV === "test" && process.env.ALLOW_UNSIGNED_TEST_IDENTITY === "1"; +const COOKIE_AUTH = !CORE_SIGNING_SECRET || ALLOW_UNSIGNED_TEST_IDENTITY; +const AUTH_MODE = COOKIE_AUTH ? "dev" : "portal"; const ALLOW = (process.env.WEB_UI_PRINCIPALS ?? "") .split(",") .map((s) => s.trim()) @@ -252,7 +254,7 @@ function resolveIdentity( name = claims.n ?? null; impersonator = claims.imp ?? null; } else { - if (CORE_SIGNING_SECRET && !ALLOW_UNSIGNED_TEST_IDENTITY) return null; + if (!COOKIE_AUTH) return null; user = cookie(req, "webuiuser"); name = cookie(req, "webuiuser_name"); impersonator = cookie(req, "webui_impersonator"); @@ -701,6 +703,7 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { } if (method === "POST" && path === "/signin") { + if (!COOKIE_AUTH) return json(res, 404, { error: "not_found" }); const body = await readBody(req); const id = (() => { try { @@ -709,7 +712,12 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { return ""; } })(); - if (!id || (ALLOW.length > 0 && !ALLOW.includes(id))) return json(res, 403, { error: "not allowed" }); + if (!id) return json(res, 400, { error: "bad_request", message: "Enter a principal to sign in as." }); + if (ALLOW.length > 0 && !ALLOW.includes(id)) + return json(res, 403, { + error: "not_allowed", + message: `${id.slice(0, 120)} isn't in this instance's allowed principals. Add it to WEB_UI_PRINCIPALS, or leave that unset to allow any principal.`, + }); res.writeHead(200, { "set-cookie": sessionCookie(id), "content-type": "application/json", @@ -741,7 +749,7 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { if (path === "/me" || path.startsWith("/api/")) { const user = cookieUser(req); - if (!user) return json(res, 401, { error: "sign in" }); + if (!user) return json(res, 401, { error: "sign in", mode: AUTH_MODE }); if (path === "/me") { res.setHeader("set-cookie", sessionCookie(user)); @@ -749,6 +757,7 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { return json(res, 200, { user, org: ORG, + mode: AUTH_MODE, slackWorkspaceUrl: await slackWorkspaceUrl(), impersonatedBy: resolveIdentity(req)?.impersonator ?? null, permissions, @@ -1824,7 +1833,7 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { if (method === "GET" && path.startsWith("/deployments/")) { const user = cookieUser(req); - if (!user) return json(res, 401, { error: "sign in" }); + if (!user) return json(res, 401, { error: "sign in", mode: AUTH_MODE }); const rest = path.slice("/deployments/".length); const slash = rest.indexOf("/"); const id = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash)); @@ -1896,7 +1905,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { ); if (!WEB_UI_DEV && !existsSync(join(DIST, "index.html"))) console.warn("[web-ui] dist-web/ not built — run `npm run build`"); - if (ALLOW.length === 0) + if (COOKIE_AUTH && ALLOW.length === 0) console.warn("[web-ui] WEB_UI_PRINCIPALS unset — any principal id may sign in (dev only)"); const t = setInterval(() => void drainWebDeliveries(), WEB_DELIVERY_POLL_MS); t.unref?.(); diff --git a/plugins/web-ui/src/main.ts b/plugins/web-ui/src/main.ts index fc265b087..7e87a7ec3 100644 --- a/plugins/web-ui/src/main.ts +++ b/plugins/web-ui/src/main.ts @@ -1,6 +1,7 @@ import "dockview-core/dist/styles/dockview.css"; import "./shell.css"; -import { boot } from "./shell"; +import { swallow } from "../../chassis/src/errors"; +import { appState, boot, renderAuthGate } from "./shell"; import { closeFormMenus } from "./ui"; import { drawActiveChat } from "./chat"; import { composerState, slashQuery } from "./composer"; @@ -44,4 +45,7 @@ document.addEventListener("keydown", (e) => { if (changed) drawActiveChat(); }); -void boot(); +void boot().catch((e: unknown) => { + if (appState.me) swallow("web-ui: boot", e); + else renderAuthGate({ kind: "unreachable" }); +}); diff --git a/plugins/web-ui/src/shell-state.ts b/plugins/web-ui/src/shell-state.ts index 45b24dafe..032ce3a89 100644 --- a/plugins/web-ui/src/shell-state.ts +++ b/plugins/web-ui/src/shell-state.ts @@ -1,6 +1,9 @@ +export type AuthMode = "portal" | "dev"; + export interface Me { user: string; org: string; + mode?: AuthMode; slackWorkspaceUrl?: string | null; impersonatedBy?: string | null; permissions?: string[]; diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index 2f8b4cbe2..1b30ad5a8 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -2,7 +2,6 @@ :root { --app-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --app-serif: ui-serif, Georgia, "Times New Roman", Times, serif; --sidebar-w: 280px; --content-w: 820px; --radius-sm: 8px; @@ -10,6 +9,7 @@ --radius-lg: 16px; --chat-pad: 22px; --brand-accent: #4f46e5; + --dev-accent: #8a5a00; --brand-mark: "A"; } @@ -2468,7 +2468,7 @@ body.resizing-sidebar { grid-template-rows: auto auto minmax(0, 1fr); } -.impersonation-banner { +.top-banner { position: fixed; top: 0; left: 0; @@ -2486,10 +2486,13 @@ body.resizing-sidebar { font-size: 13px; font-weight: 500; } -.impersonation-banner b { +.top-banner.dev { + background: var(--dev-accent); +} +.top-banner b { font-weight: 700; } -.impersonation-banner-exit { +.top-banner-action { font: inherit; font-weight: 600; cursor: pointer; @@ -2499,10 +2502,10 @@ body.resizing-sidebar { border-radius: 6px; padding: 4px 12px; } -.impersonation-banner-exit:hover { +.top-banner-action:hover { background: rgba(0, 0, 0, 0.3); } -.layout.impersonating { +.layout.bannered { --surface-safe-top: 0px; height: calc(100vh - 38px - env(safe-area-inset-top)); height: calc(100dvh - 38px - env(safe-area-inset-top)); @@ -3887,6 +3890,7 @@ body.resizing-sidebar { padding: 24px; box-sizing: border-box; } +.signin-panel, .signin form { display: flex; flex-direction: column; @@ -3894,6 +3898,9 @@ body.resizing-sidebar { width: 340px; max-width: 100%; } +.signin form { + width: auto; +} .signin-brand { display: flex; align-items: center; @@ -3903,6 +3910,35 @@ body.resizing-sidebar { letter-spacing: 0; margin-bottom: 4px; } +.signin-brand .dev-chip { + margin-left: auto; + padding: 2px 6px; + border-radius: 4px; + background: var(--dev-accent); + color: #fff; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; +} +.signin-body { + font-size: 13.5px; + color: var(--muted-foreground); + line-height: 1.5; + margin: -4px 0 2px; +} +.signin-body b { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.9em; + font-weight: 600; + color: var(--foreground); +} +.signin label { + font-size: 12px; + font-weight: 600; + color: var(--muted-foreground); + margin-bottom: -6px; +} .signin-brand .brand-mark { width: 22px; height: 22px; @@ -3910,10 +3946,10 @@ body.resizing-sidebar { font-size: 16px; } .signin h1 { - font-family: var(--app-serif); - font-size: 26px; - font-weight: 500; + font-size: 22px; + font-weight: 600; letter-spacing: 0; + line-height: 1.2; margin: 0 0 2px; } .signin input { @@ -3935,6 +3971,9 @@ body.resizing-sidebar { color: var(--muted-foreground); line-height: 1.45; } +.signin .hint.error { + color: var(--destructive, #c00); +} @media (max-width: 860px) { :root { diff --git a/plugins/web-ui/src/shell.ts b/plugins/web-ui/src/shell.ts index fba3adf41..40a8d7d73 100644 --- a/plugins/web-ui/src/shell.ts +++ b/plugins/web-ui/src/shell.ts @@ -62,10 +62,12 @@ import { renderDeploys } from "./deploys"; import { renderMemory, resetMemoryState } from "./memory"; import { renderSkills } from "./skills"; import { contextsState, ensureContexts, renderContexts, resetContextsState } from "./contexts"; -import { appState, isView, type Me, type View } from "./shell-state"; +import { appState, isView, type AuthMode, type Me, type View } from "./shell-state"; import { trapDialogFocus } from "./dialog-focus"; export { appState, can, type Me, type View } from "./shell-state"; +let authMode: AuthMode = "portal"; + export const ADMIN_BASE = (() => { const base = ((import.meta as unknown as { env?: { BASE_URL?: string } }).env?.BASE_URL ?? "/").replace(/\/$/, ""); return base ? base.replace(/\/[^/]+$/, "/admin") : "/admin"; @@ -193,7 +195,16 @@ export async function signOut(): Promise { resetContextsState(); resetKeychainState(); resetComposer(); - renderSignin(); + if (authMode === "portal") { + try { + await fetch("/auth/logout", { method: "POST", headers: { accept: "application/json" } }); + } catch { + void 0; + } + location.href = "/"; + return; + } + renderAuthGate({ kind: "dev" }); } export async function exitImpersonation(): Promise { @@ -207,43 +218,104 @@ export async function exitImpersonation(): Promise { function impersonationBanner(by: string) { return html` -
- Viewing the assistant as ${appState.me?.user ?? ""} — you are ${by} - +
+ Viewing the assistant as ${appState.me?.user ?? ""} — you are ${by} +
`; } -export function renderSignin(errorText = ""): void { - render( - html` -