From 29e06ee8209f1bbea26100573bb7f01c4a35582d Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 01:22:50 +0900 Subject: [PATCH 1/6] docs(platformclaw): plan web login bootstrap --- docs/platformclaw/decisions.md | 18 + docs/platformclaw/index.md | 1 + docs/platformclaw/web-login-bootstrap-plan.md | 336 ++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 docs/platformclaw/web-login-bootstrap-plan.md diff --git a/docs/platformclaw/decisions.md b/docs/platformclaw/decisions.md index 8d719d1b3ec..2f27f62e004 100644 --- a/docs/platformclaw/decisions.md +++ b/docs/platformclaw/decisions.md @@ -244,6 +244,24 @@ because the operator path would also disable bound administrator cron jobs. Abort requests target the authenticated session only; browsers cannot select a run ID because the shared operator connection cannot prove ownership of it. +### PC-117 Bootstrap Web through a narrow Control UI adapter + +PlatformClaw serves a separate employee login shell and the upstream Control UI +from the same public origin as `platformclaw-control`. The authenticated app +uses the opaque browser cookie and the shared Web Gateway policy proxy. It does +not receive a Gateway credential or use a per-user proxy or Gateway client. + +The first post-login surface reuses upstream chat and session components and +shows only chat, new-session, and personal-session routes. A single +PlatformClaw UI adapter owns the fixed same-origin Gateway URL, session-expiry +redirect, route availability, identity summary, and logout. Detailed visual +design is deferred. UI hiding is not authorization; PC-116 remains the +request/event security boundary. + +The legacy employee entry is behavioral reference only. Its browser bootstrap +token, browser-provided agent/session routing, and broad per-page employee-mode +branches are not migrated. + ## Open operational decisions No remaining decision blocks the SQLite v1 store. Deployment work still needs diff --git a/docs/platformclaw/index.md b/docs/platformclaw/index.md index 518787ecbaa..79a3c58d37d 100644 --- a/docs/platformclaw/index.md +++ b/docs/platformclaw/index.md @@ -25,6 +25,7 @@ and Knox direct or group conversations: - [Architecture decisions](/platformclaw/decisions) - [Employee authentication](/platformclaw/employee-auth) - [Web ingress runtime](/platformclaw/web-ingress-runtime) +- [Web login bootstrap plan](/platformclaw/web-login-bootstrap-plan) - [Private downstream CI](/platformclaw/private-downstream-ci) The first five implementation slices are in the private diff --git a/docs/platformclaw/web-login-bootstrap-plan.md b/docs/platformclaw/web-login-bootstrap-plan.md new file mode 100644 index 00000000000..aa75847b3b6 --- /dev/null +++ b/docs/platformclaw/web-login-bootstrap-plan.md @@ -0,0 +1,336 @@ +--- +summary: "Implementation plan for PlatformClaw sign-in and the authenticated Control UI shell" +read_when: + - Implementing the PlatformClaw employee login page + - Hosting the upstream Control UI behind PlatformClaw browser sessions + - Changing post-login navigation or session-expiry behavior +title: "Web login bootstrap plan" +--- + +# Web login bootstrap plan + +This slice turns the existing browser-auth and Gateway ingress contracts into a +usable employee web application. It adds a small PlatformClaw-owned login shell +and a narrow adapter around the upstream Control UI. It does not fork the chat +application or add enterprise authentication to the OpenClaw Gateway. + +Implementation starts after the Web Gateway proxy and Web ingress runtime +branches are on `main`. Detailed visual design, branding, animation, and final +copy are intentionally deferred. The functional states and security boundary +defined here are not deferred. + +## Outcome + +After this slice: + +1. An unauthenticated browser sees the PlatformClaw employee login page. +2. A successful password login provisions or reuses the employee's personal + agent and issues only an opaque `HttpOnly` browser cookie. +3. The browser enters the upstream Control UI on the personal agent's main + session without seeing a Gateway URL, token, password, or agent selector. +4. The first UI exposes chat and personal session management. Unsupported + operator routes are absent and direct navigation to them returns to chat. +5. Logout and browser-session expiry close the WebSocket and return the browser + to the employee login page. +6. A private Gateway restart reconnects the UI without logging the employee + out. + +## Fixed topology + +The topology remains the one approved by PC-005 and PC-116: + +```text +browser + -> one platformclaw-control HTTP/WebSocket process + -> one BrowserGatewayProxy policy instance + -> one private Gateway client + -> one OpenClaw Gateway +``` + +Users do not receive proxy instances or private Gateway connections. Per-user +state consists of the opaque browser session and the agent/session access +context resolved by the shared policy layer. + +The login page, application document, authentication APIs, and WebSocket use +one configured public origin. Cross-origin browser deployment is out of scope. + +## Browser routes + +| Path | Owner | Behavior | +| -------------------------------- | ------------------------ | ----------------------------------------------------------------- | +| `/platformclaw/login` | PlatformClaw login shell | Show password login or redirect an active session to the app | +| `/platformclaw/app/*` | Control UI host | Require an active browser session before serving the SPA document | +| `/platformclaw/api/auth/login` | Browser auth service | Authenticate, provision, and set the opaque cookie | +| `/platformclaw/api/auth/logout` | Browser auth service | Revoke the session and clear the cookie | +| `/platformclaw/api/auth/session` | Browser auth service | Return current user and expiry state | +| `/platformclaw/gateway` | Web ingress | Carry the policy-filtered Gateway protocol | +| `/platformclaw/health` | Web ingress | Report private Gateway readiness | + +Static hashed assets may be served without a session because they contain no +employee data. The SPA document and all data APIs remain session-gated. A deep +link under `/platformclaw/app/*` preserves its path through login only when the +return target is a same-origin path under that exact prefix. + +## Login flow + +The login shell is PlatformClaw-owned and does not load the full Control UI. + +1. `GET /platformclaw/login` serves a small HTML, CSS, and JavaScript bundle. +2. The shell calls `GET /platformclaw/api/auth/session` with same-origin + credentials. +3. An active session redirects to `/platformclaw/app/chat` or a validated + `returnTo` path. +4. Otherwise the employee enters an account ID and password. +5. The shell posts JSON to `/platformclaw/api/auth/login` with the exact public + origin and same-origin credentials. +6. While the request runs, the page shows a workspace preparation state. The + current login service completes personal-agent provisioning before returning + success, so a separate browser polling protocol is not needed. +7. Success clears the password field and redirects to the app. Failure keeps + the account ID, clears the password, and shows a bounded product message. + +The browser never stores the password. The authentication service URL and its +optional bearer remain server-side deployment configuration. Authentication +responses cannot select the PlatformClaw agent or session. + +Initial error mapping: + +| HTTP status | UI state | +| ------------ | ------------------------------------------------------------ | +| `400`, `415` | Invalid request; allow correction | +| `401` | Invalid credentials | +| `403` | Account disabled or request origin denied | +| `409` | Browser session limit reached | +| `429` | Rate limited; respect `Retry-After` | +| `503` | Authentication or agent provisioning temporarily unavailable | + +Exact wording and visual treatment remain part of the later UI design pass. + +## Authenticated app bootstrap + +The BFF validates the browser cookie before serving an application document. +The document includes a non-secret, PlatformClaw-owned runtime descriptor with: + +- the fixed same-origin Gateway WebSocket path; +- the login, logout, and session paths; +- the initial functional route allowlist; +- a flag that enables PlatformClaw browser-session behavior. + +The descriptor contains no employee profile, agent ID, session key, Gateway +credential, or internal service URL. User identity comes from the session API; +agent and main-session defaults come from the filtered Gateway hello. + +The initial descriptor contract is deliberately small: + +```ts +type PlatformClawWebDescriptor = { + mode: "platformclaw"; + gatewayPath: "/platformclaw/gateway"; + loginPath: "/platformclaw/login"; + logoutPath: "/platformclaw/api/auth/logout"; + sessionPath: "/platformclaw/api/auth/session"; + enabledRoutes: ["chat", "new-session", "sessions"]; +}; +``` + +The host emits only these literal route values. The browser parser rejects +unknown fields and values instead of treating the descriptor as arbitrary +configuration. + +A small UI adapter reads this descriptor before the normal Control UI runtime +starts. It supplies the same-origin `/platformclaw/gateway` URL directly to the +existing application Gateway store with empty token and password fields. The +adapter must disable persisted remote-Gateway selection and cached device-token +fallback for this mode. Cookie authentication is the only browser authority. + +The normal Gateway hello then supplies `defaultAgentId` and `mainSessionKey`. +The existing Control UI session resolver uses those server-projected values; +the browser does not construct or choose another agent binding. + +## First post-login surface + +The initial surface reuses the upstream Control UI chat and session components. +It does not recreate chat rendering, streaming, attachments, tool cards, model +selection, or session history. + +The first functional route allowlist is: + +- Chat, including the personal main session. +- New session, constrained by the BFF to the owned personal agent. +- Sessions, constrained by the BFF to owned session keys. + +The shell adds only a compact employee identity and logout control. Agent, +channel, plugin, node, configuration, approvals, usage, and other operator +navigation is hidden. Direct navigation to a disabled route is replaced with +the chat route. This is presentation only; `BrowserGatewayProxy` remains the +authorization boundary for every request and event. + +The route list may expand only after the matching Gateway method and parameter +policy has been reviewed. A hidden route never implies that an RPC is safe, and +an advertised upstream method never automatically enables a route. + +## Session and connection state + +The adapter uses these states: + +```text +checking-session + -> unauthenticated -> login + -> authenticated -> connecting -> ready + -> gateway-unavailable -> reconnecting +ready -> logging-out -> login +ready -> session-expired -> login +``` + +Behavior is deterministic: + +- WebSocket close `1012` means the private Gateway restarted. Keep the app + mounted and use the existing bounded reconnect behavior. +- WebSocket close `1008` from an inactive browser session triggers one session + API check. An inactive result redirects to login. An active result is treated + as a protocol or policy error and does not create a login loop. +- A session API response with `authenticated: false` clears browser-only UI + state and redirects to login. +- Logout first posts to the logout API, then stops the Gateway client, clears + browser-only session selection, and redirects even if the response fails. +- Refreshing or opening a deep link repeats the server-side app-document gate; + the browser cannot render stale employee data before authentication. + +## Minimal upstream divergence + +The implementation must keep PlatformClaw UI behavior in one adapter module +and one PlatformClaw-owned login bundle. Expected upstream touch points are: + +1. one startup call before `bootstrapApplication` starts the Gateway; +2. one route-availability input consumed by the existing sidebar and router; +3. one identity/logout presentation hook in the application shell. + +Do not add `employeeMode` conditionals across individual pages. Do not copy the +legacy employee UI, fork the chat view, or add PlatformClaw policy to upstream +Gateway methods. If an existing generic Control UI hook can satisfy a touch +point at implementation time, use it instead of adding a PlatformClaw-specific +core branch. + +The legacy system is evidence for the separate employee entry, workspace +preparation state, identity summary, and logout behavior. Its browser bootstrap +token, browser-supplied agent/session routing, and broad per-page employee mode +are explicitly not migration targets. + +## Hosting and deployment composition + +The deployable `platformclaw-control` entry point will assemble the existing +Web ingress runtime and the UI asset host. Deployment supplies: + +- public origin and listen address; +- persistent control-plane database path; +- employee-auth login URL and optional service bearer; +- initial administrator account IDs from a Docker secret; +- private Gateway URL and operator credential from a Docker secret; +- the built Control UI and PlatformClaw login asset roots. + +Exact new environment-variable names are chosen in the deployment slice only +after existing configuration surfaces are checked. Secret values never appear +in generated HTML, process arguments, logs, health responses, or browser +configuration. + +The final authority is an Ubuntu Linux container. The production image runs one +control process and one OpenClaw Gateway process under supervision; it does not +start a process for each employee. + +## Non-goals + +This slice does not implement SAML, Knox routing, VM selection, SafeConnect +credential storage, or sandbox execution. Those capabilities consume the same +identity and agent binding later but do not change browser bootstrap. + +Initial `USER.md` profile injection also remains outside this slice. The login +service already retains the approved directory profile, but PC-115 requires a +separate atomic, workspace-owned Gateway or plugin contract before writing it. +The UI implementation must not reintroduce the legacy whole-file mutation path. + +## Security requirements + +- Keep `HttpOnly`, `Secure`, and `SameSite=Lax` on the browser cookie in + production. +- Require the exact configured public origin for login, logout, and WebSocket + upgrade. +- Add `Cache-Control: no-store` to login, app documents, and auth responses. +- Apply a restrictive CSP to both documents; allow only the built asset origin + and the same-origin WebSocket. +- Validate `returnTo` as a path, never as an absolute or protocol-relative URL. +- Do not trust forwarding headers unless a deployment-owned trusted-proxy + resolver is explicitly configured. +- Do not log login bodies, cookies, employee profiles, Gateway frames, or + secrets. +- Keep UI route filtering separate from BFF authorization tests. + +## Implementation slices + +### Slice A: UI hosting contract + +- Add typed runtime descriptor and strict parser. +- Add authenticated SPA document gate and safe `returnTo` handling. +- Serve built static assets with path traversal and cache-control tests. +- Prove that no descriptor field can carry a credential or routing identity. + +### Slice B: Login shell + +- Add the minimal login bundle and session check. +- Implement login states and status mapping. +- Implement active-session and validated-return redirects. +- Test with `scripts/mock_employee_auth.py` and multiple mock accounts. + +### Slice C: Control UI adapter + +- Set the fixed same-origin Gateway URL without token persistence. +- Consume server-projected agent and main-session defaults. +- Apply route allowlisting and direct-route fallback. +- Add identity, logout, expiry redirect, and Gateway-restart behavior. + +### Slice D: deployable runtime and proof + +- Add the Linux container entry point and secret-backed composition. +- Run focused control-plane and UI tests. +- Run Control UI Playwright proof for login, chat bootstrap, logout, expiry, + cross-agent denial, deep links, and private Gateway restart. +- Build and smoke-test the Ubuntu image with the loopback mock auth service. + +Each slice should be a reviewable commit. Heavy Linux build and browser proof +runs in the background after focused local tests pass. + +## Verification matrix + +| Scenario | Required result | +| ----------------------------- | ---------------------------------------------------------- | +| No cookie opens app deep link | Redirect to login with a bounded same-origin return path | +| Valid login | One session cookie, one personal agent, main session opens | +| Concurrent first login | One personal-agent binding is created | +| Forged agent or session input | BFF denies and audits the request | +| Fourth active browser login | Login page shows session-limit state | +| Logout | Session revoked, WebSocket stopped, login shown | +| Idle or absolute expiry | Existing WebSocket closes and login is shown | +| Private Gateway restart | UI reconnects without employee reauthentication | +| Disabled route deep link | UI returns to chat; no operator RPC is sent | +| Stale browser device token | It is neither sent nor used as PlatformClaw authority | + +## Deferred UI decisions + +These do not block functional implementation: + +- exact layout, colors, typography, logo, mascot, and animation; +- Korean or English final copy and localization policy; +- announcement, documentation, and release-note presentation; +- administrator screen placement; +- expanded user-facing routes after their BFF policy exists; +- SAML redirect and callback presentation; +- VM profile, SafeConnect credential, and sandbox status presentation. + +Any later `design.md` rules apply to those surfaces without changing the +identity, session, Gateway, or authorization boundary in this plan. + +## See also + +- [Web ingress runtime](/platformclaw/web-ingress-runtime) +- [Control plane phase 1](/platformclaw/control-plane-phase-1) +- [Employee authentication](/platformclaw/employee-auth) +- [Architecture decisions](/platformclaw/decisions) From 3b17eaaade667a46dfe1c3bbf37427bd4d6ee499 Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 02:02:33 +0900 Subject: [PATCH 2/6] docs(platformclaw): define UI validation loop --- docs/platformclaw/web-login-bootstrap-plan.md | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/platformclaw/web-login-bootstrap-plan.md b/docs/platformclaw/web-login-bootstrap-plan.md index aa75847b3b6..aa5636ebdc1 100644 --- a/docs/platformclaw/web-login-bootstrap-plan.md +++ b/docs/platformclaw/web-login-bootstrap-plan.md @@ -237,6 +237,37 @@ The final authority is an Ubuntu Linux container. The production image runs one control process and one OpenClaw Gateway process under supervision; it does not start a process for each employee. +## UI development and validation loop + +Use Windows for the short visual feedback loop and Ubuntu Docker for release +authority. Rebuilding the production image after each layout or copy adjustment +is not required. + +The implementation loop is: + +1. Start `scripts/mock_employee_auth.py`, the local PlatformClaw Web runtime, + and the Control UI development server on Windows. Use only synthetic mock + accounts and credentials. +2. Open the login and authenticated app routes in a Windows browser. Adjust the + layout, responsive states, copy, loading behavior, and errors while observing + the actual page. Check at least the login, preparing-workspace, chat-ready, + session-expired, and unavailable states affected by the change. +3. During iteration, run formatting, UI typecheck, and only the focused Vitest + or mocked Playwright scenario covering the changed state. Keep visual + iteration independent from the production image build. +4. At the end of each functional slice, run the complete focused control-plane + and UI test set, then build the UI assets. Record screenshots when a review + needs visual comparison. +5. After all four slices are integrated, build the Ubuntu image with + `pnpm platformclaw:build` and run the final Playwright E2E against the + containerized Web runtime, mock employee-auth service, and private Gateway. + +Windows proves development behavior and visual intent. The container E2E proves +Linux path casing, asset hosting, process composition, cookie behavior, +WebSocket routing, and browser flows in the deployable artifact. A Windows-only +pass cannot replace that final proof, and UI implementation must not introduce +Windows-only paths or runtime dependencies. + ## Non-goals This slice does not implement SAML, Knox routing, VM selection, SafeConnect @@ -296,7 +327,9 @@ The UI implementation must not reintroduce the legacy whole-file mutation path. - Build and smoke-test the Ubuntu image with the loopback mock auth service. Each slice should be a reviewable commit. Heavy Linux build and browser proof -runs in the background after focused local tests pass. +runs in the background after the Windows visual loop and focused local tests +pass. Routine CSS, layout, and copy changes stay in the Windows loop until a +functional checkpoint is ready. ## Verification matrix From ccdc5489628d43957ba497a72711e7f04ac728a6 Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 03:11:03 +0900 Subject: [PATCH 3/6] feat(platformclaw): add employee login shell --- .../platformclaw-control-plane/src/index.ts | 1 + .../src/web-assets.test.ts | 93 ++++++++ .../src/web-assets.ts | 135 +++++++++++ .../src/web-ingress-runtime.test.ts | 13 ++ .../src/web-ingress-runtime.ts | 3 + .../src/web-ingress-server.ts | 5 + ui/package.json | 2 +- ui/platformclaw-login.html | 61 +++++ ui/src/platformclaw/login.css | 213 ++++++++++++++++++ ui/src/platformclaw/login.test.ts | 109 +++++++++ ui/src/platformclaw/login.ts | 177 +++++++++++++++ .../platformclaw/vite-login-path.node.test.ts | 17 ++ ui/src/platformclaw/web-contract.test.ts | 35 +++ ui/src/platformclaw/web-contract.ts | 47 ++++ ui/vite.config.ts | 9 + ui/vite.platformclaw-login.config.ts | 18 ++ 16 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 packages/platformclaw-control-plane/src/web-assets.test.ts create mode 100644 packages/platformclaw-control-plane/src/web-assets.ts create mode 100644 ui/platformclaw-login.html create mode 100644 ui/src/platformclaw/login.css create mode 100644 ui/src/platformclaw/login.test.ts create mode 100644 ui/src/platformclaw/login.ts create mode 100644 ui/src/platformclaw/vite-login-path.node.test.ts create mode 100644 ui/src/platformclaw/web-contract.test.ts create mode 100644 ui/src/platformclaw/web-contract.ts create mode 100644 ui/vite.platformclaw-login.config.ts diff --git a/packages/platformclaw-control-plane/src/index.ts b/packages/platformclaw-control-plane/src/index.ts index ead9f427d4b..4669a5cf8a8 100644 --- a/packages/platformclaw-control-plane/src/index.ts +++ b/packages/platformclaw-control-plane/src/index.ts @@ -7,6 +7,7 @@ export * from "./browser-login-rate-limiter.js"; export * from "./gateway-runtime-client.js"; export * from "./web-ingress-server.js"; export * from "./web-ingress-runtime.js"; +export * from "./web-assets.js"; export * from "./contracts.js"; export * from "./employee-auth-client.js"; export * from "./gateway-admin-rpc-client.js"; diff --git a/packages/platformclaw-control-plane/src/web-assets.test.ts b/packages/platformclaw-control-plane/src/web-assets.test.ts new file mode 100644 index 00000000000..9790ae00cd2 --- /dev/null +++ b/packages/platformclaw-control-plane/src/web-assets.test.ts @@ -0,0 +1,93 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createPlatformClawWebAssetHandler, + PLATFORMCLAW_WEB_ASSET_PREFIX, + PLATFORMCLAW_WEB_LOGIN_PATH, +} from "./web-assets.js"; + +const tempDirectories: string[] = []; + +function fixtureRoot(): string { + const root = mkdtempSync(join(tmpdir(), "platformclaw-web-assets-")); + tempDirectories.push(root); + mkdirSync(join(root, "assets")); + writeFileSync(join(root, "platformclaw-login.html"), "Login"); + writeFileSync(join(root, "assets", "login-ABC123.js"), "export const ready = true;"); + return root; +} + +async function serveFixture(root: string): Promise<{ + origin: string; + close(): Promise; +}> { + const handler = createPlatformClawWebAssetHandler(root); + const server = createServer((req, res) => { + void handler.handle(req, res).then((handled) => { + if (!handled) { + res.statusCode = 404; + res.end("Not Found"); + } + }); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const port = (server.address() as AddressInfo).port; + return { + origin: `http://127.0.0.1:${port}`, + close: async () => + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }), + }; +} + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("createPlatformClawWebAssetHandler", () => { + it("serves the login contract path with document security headers", async () => { + const fixture = await serveFixture(fixtureRoot()); + try { + const response = await fetch(`${fixture.origin}${PLATFORMCLAW_WEB_LOGIN_PATH}`); + expect(response.status).toBe(200); + expect(await response.text()).toContain("Login"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + } finally { + await fixture.close(); + } + }); + + it("serves only indexed build assets with immutable caching", async () => { + const fixture = await serveFixture(fixtureRoot()); + try { + const asset = await fetch(`${fixture.origin}${PLATFORMCLAW_WEB_ASSET_PREFIX}login-ABC123.js`); + expect(asset.status).toBe(200); + expect(asset.headers.get("cache-control")).toContain("immutable"); + expect(asset.headers.get("content-type")).toContain("text/javascript"); + + const traversal = await fetch( + `${fixture.origin}${PLATFORMCLAW_WEB_ASSET_PREFIX}%2e%2e%2fplatformclaw-login.html`, + ); + expect(traversal.status).toBe(404); + } finally { + await fixture.close(); + } + }); +}); diff --git a/packages/platformclaw-control-plane/src/web-assets.ts b/packages/platformclaw-control-plane/src/web-assets.ts new file mode 100644 index 00000000000..5cfd6951e4a --- /dev/null +++ b/packages/platformclaw-control-plane/src/web-assets.ts @@ -0,0 +1,135 @@ +import { readdirSync, realpathSync, statSync, type Dirent } from "node:fs"; +import { readFile } from "node:fs/promises"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { extname, join, relative, resolve, sep } from "node:path"; + +export const PLATFORMCLAW_WEB_LOGIN_PATH = "/platformclaw/login"; +export const PLATFORMCLAW_WEB_ASSET_PREFIX = "/platformclaw/assets/"; + +export type PlatformClawWebAssetHandler = { + handle(req: IncomingMessage, res: ServerResponse): Promise; +}; + +const CONTENT_TYPES: Readonly> = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".wasm": "application/wasm", + ".webp": "image/webp", +}; + +const DOCUMENT_SECURITY_POLICY = [ + "default-src 'self'", + "base-uri 'none'", + "connect-src 'self' ws: wss:", + "form-action 'self'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", +].join("; "); + +type WebAsset = { + filePath: string; + contentType: string; +}; + +function assertRegularFileInsideRoot(root: string, filePath: string): string { + const resolved = realpathSync(filePath); + if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) { + throw new Error(`PlatformClaw web asset escapes root: ${filePath}`); + } + if (!statSync(resolved).isFile()) { + throw new Error(`PlatformClaw web asset is not a file: ${filePath}`); + } + return resolved; +} + +function collectAssetFiles(root: string, directory: string): Map { + const assets = new Map(); + const visit = (current: string, entries: Dirent[]): void => { + for (const entry of entries) { + if (entry.isSymbolicLink()) { + throw new Error(`PlatformClaw web assets must not contain symlinks: ${entry.name}`); + } + const candidate = join(current, entry.name); + if (entry.isDirectory()) { + visit(candidate, readdirSync(candidate, { withFileTypes: true })); + continue; + } + if (!entry.isFile()) { + continue; + } + const filePath = assertRegularFileInsideRoot(root, candidate); + const assetPath = relative(directory, filePath).split(sep).join("/"); + assets.set(`${PLATFORMCLAW_WEB_ASSET_PREFIX}${assetPath}`, { + filePath, + contentType: CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream", + }); + } + }; + visit(directory, readdirSync(directory, { withFileTypes: true })); + return assets; +} + +function setSecurityHeaders(res: ServerResponse): void { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("X-Frame-Options", "DENY"); +} + +export function createPlatformClawWebAssetHandler( + rootDirectory: string, +): PlatformClawWebAssetHandler { + const root = realpathSync(resolve(rootDirectory)); + const loginFile = assertRegularFileInsideRoot(root, join(root, "platformclaw-login.html")); + const assetsDirectory = realpathSync(join(root, "assets")); + if (!assetsDirectory.startsWith(`${root}${sep}`)) { + throw new Error("PlatformClaw web assets directory escapes root"); + } + const assets = collectAssetFiles(root, assetsDirectory); + + return { + async handle(req, res) { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + const isLogin = pathname === PLATFORMCLAW_WEB_LOGIN_PATH; + const asset = assets.get(pathname); + if (!isLogin && !asset) { + return false; + } + const filePath = isLogin ? loginFile : asset?.filePath; + if (!filePath) { + return false; + } + if (req.method !== "GET" && req.method !== "HEAD") { + res.statusCode = 405; + res.setHeader("Allow", "GET, HEAD"); + res.end("Method Not Allowed"); + return true; + } + + setSecurityHeaders(res); + if (isLogin) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Content-Security-Policy", DOCUMENT_SECURITY_POLICY); + } else if (asset) { + res.setHeader("Content-Type", asset.contentType); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } + res.statusCode = 200; + if (req.method === "HEAD") { + res.end(); + return true; + } + res.end(await readFile(filePath)); + return true; + }, + }; +} diff --git a/packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts b/packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts index 03688ea1f96..24b287bf364 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-runtime.test.ts @@ -1,3 +1,7 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { GatewayClientOptions } from "@openclaw/gateway-client"; import type { HelloOk } from "@openclaw/gateway-protocol"; import { describe, expect, it, vi } from "vitest"; @@ -22,6 +26,9 @@ function hello(): HelloOk { describe("createPlatformClawWebIngressRuntime", () => { it("assembles employee auth, the private Gateway, policy proxy, and listener", async () => { + const controlUiRoot = mkdtempSync(join(tmpdir(), "platformclaw-ingress-ui-")); + mkdirSync(join(controlUiRoot, "assets")); + writeFileSync(join(controlUiRoot, "platformclaw-login.html"), "Login"); let clientOptions: GatewayClientOptions | undefined; const stop = vi.fn(); const provisionOrRefresh = vi.fn(async () => undefined); @@ -58,12 +65,17 @@ describe("createPlatformClawWebIngressRuntime", () => { }, }, publicOrigin: "http://127.0.0.1:3000", + controlUiRoot, }); try { await runtime.listen({ host: "127.0.0.1", port: 0 }); expect(clientOptions?.token).toBe("test-auth-token"); expect(runtime.gateway.getHello()).toEqual(hello()); + const port = (runtime.server.address() as AddressInfo).port; + const loginPage = await fetch(`http://127.0.0.1:${port}/platformclaw/login`); + expect(loginPage.status).toBe(200); + expect(await loginPage.text()).toContain("Login"); await expect( runtime.auth.service.loginPassword({ @@ -77,6 +89,7 @@ describe("createPlatformClawWebIngressRuntime", () => { expect(provisionOrRefresh).toHaveBeenCalledOnce(); } finally { await runtime.close(); + rmSync(controlUiRoot, { recursive: true, force: true }); } expect(stop).toHaveBeenCalledOnce(); }); diff --git a/packages/platformclaw-control-plane/src/web-ingress-runtime.ts b/packages/platformclaw-control-plane/src/web-ingress-runtime.ts index fc77ef958cd..7bb5afb54ec 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-runtime.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-runtime.ts @@ -14,6 +14,7 @@ import { PlatformClawGatewayRuntimeClient, type PlatformClawGatewayRuntimeClientOptions, } from "./gateway-runtime-client.js"; +import { createPlatformClawWebAssetHandler } from "./web-assets.js"; import { PlatformClawWebIngressServer, type PlatformClawWebIngressListenOptions, @@ -32,6 +33,7 @@ export type PlatformClawWebIngressRuntimeOptions = { >; gatewayClient: PlatformClawGatewayRuntimeClientOptions; publicOrigin: string; + controlUiRoot: string; loginRateLimiter?: MemoryBrowserLoginRateLimiterOptions; ingress?: Pick< PlatformClawWebIngressOptions, @@ -75,6 +77,7 @@ export function createPlatformClawWebIngressRuntime( loginRateLimiter: new MemoryBrowserLoginRateLimiter(options.loginRateLimiter), gatewayProxy, gateway, + webAssets: createPlatformClawWebAssetHandler(options.controlUiRoot), ...options.ingress, }); let closed = false; diff --git a/packages/platformclaw-control-plane/src/web-ingress-server.ts b/packages/platformclaw-control-plane/src/web-ingress-server.ts index c31be3b2c74..aeb615ac3ab 100644 --- a/packages/platformclaw-control-plane/src/web-ingress-server.ts +++ b/packages/platformclaw-control-plane/src/web-ingress-server.ts @@ -27,6 +27,7 @@ import { type BrowserGatewayEvent, } from "./browser-gateway-proxy.js"; import type { PlatformClawGatewayBackend } from "./gateway-runtime-client.js"; +import type { PlatformClawWebAssetHandler } from "./web-assets.js"; export const PLATFORMCLAW_GATEWAY_PATH = "/platformclaw/gateway"; export const PLATFORMCLAW_HEALTH_PATH = "/platformclaw/health"; @@ -47,6 +48,7 @@ export type PlatformClawWebIngressOptions = { loginRateLimiter: BrowserLoginRateLimiter; gatewayProxy: PlatformClawBrowserGatewayPolicy; gateway: PlatformClawGatewayBackend; + webAssets?: PlatformClawWebAssetHandler; gatewayPath?: string; healthPath?: string; maxPayloadBytes?: number; @@ -269,6 +271,9 @@ export class PlatformClawWebIngressServer { if (handled) { return; } + if (this.options.webAssets && (await this.options.webAssets.handle(req, res))) { + return; + } const pathname = new URL(req.url ?? "/", this.publicOrigin).pathname; if (pathname === this.healthPath && (req.method === "GET" || req.method === "HEAD")) { const ready = this.options.gateway.getHello() !== null; diff --git a/ui/package.json b/ui/package.json index 594e42a59e1..991b9bd0988 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "build": "vite build", + "build": "vite build && vite build --config vite.platformclaw-login.config.ts", "dev": "vite", "preview": "vite preview", "test": "vitest run --config vitest.config.ts" diff --git a/ui/platformclaw-login.html b/ui/platformclaw-login.html new file mode 100644 index 00000000000..ae3fed36c4e --- /dev/null +++ b/ui/platformclaw-login.html @@ -0,0 +1,61 @@ + + + + + + + + + PlatformClaw 로그인 + + + +
+ + + +
+ + + diff --git a/ui/src/platformclaw/login.css b/ui/src/platformclaw/login.css new file mode 100644 index 00000000000..ab8c7300947 --- /dev/null +++ b/ui/src/platformclaw/login.css @@ -0,0 +1,213 @@ +:root { + color-scheme: dark; + font-family: + Inter, + Pretendard, + "Noto Sans KR", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + color: #f6f7fb; + background: #090b11; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + display: grid; + min-height: 100dvh; + place-items: center; + overflow-x: hidden; + background: + radial-gradient(circle at 16% 12%, rgba(88, 115, 220, 0.2), transparent 34rem), + radial-gradient(circle at 92% 88%, rgba(77, 183, 183, 0.11), transparent 30rem), #090b11; +} + +.login-page { + width: min(100%, 31rem); + padding: 2rem 1.25rem; +} + +.login-brand { + display: flex; + align-items: center; + gap: 0.8rem; + margin: 0 0 1.5rem 0.25rem; +} + +.login-mark { + display: grid; + width: 2.4rem; + height: 2.4rem; + place-items: center; + border: 1px solid rgba(151, 174, 255, 0.35); + border-radius: 0.8rem; + color: #dce4ff; + background: linear-gradient(145deg, rgba(124, 156, 255, 0.28), rgba(71, 94, 176, 0.08)); + box-shadow: 0 0 28px rgba(90, 122, 235, 0.16); + font-weight: 750; + letter-spacing: -0.06em; +} + +.login-brand__name { + font-size: 1.02rem; + font-weight: 680; + letter-spacing: -0.02em; +} + +.login-brand__label { + margin-top: 0.12rem; + color: #9da7bc; + font-size: 0.73rem; +} + +.login-card { + padding: clamp(1.5rem, 5vw, 2.25rem); + border: 1px solid rgba(160, 174, 207, 0.15); + border-radius: 1.35rem; + background: rgba(18, 21, 30, 0.88); + box-shadow: 0 1.5rem 5rem rgba(0, 0, 0, 0.36); + backdrop-filter: blur(18px); +} + +h1 { + margin: 0; + font-size: clamp(1.65rem, 6vw, 2rem); + line-height: 1.25; + letter-spacing: -0.045em; +} + +.login-intro { + margin: 0.65rem 0 1.8rem; + color: #9da7bc; + font-size: 0.93rem; + line-height: 1.6; +} + +form { + display: grid; + gap: 1rem; +} + +label { + display: grid; + gap: 0.5rem; + color: #cbd1df; + font-size: 0.82rem; + font-weight: 620; +} + +input { + width: 100%; + min-height: 3.15rem; + padding: 0 0.95rem; + border: 1px solid rgba(160, 174, 207, 0.2); + border-radius: 0.78rem; + outline: none; + color: #f6f7fb; + background: rgba(8, 10, 16, 0.72); + font: inherit; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background 140ms ease; +} + +input:hover { + border-color: rgba(160, 174, 207, 0.38); +} + +input:focus { + border-color: #7c9cff; + background: #0b0e16; + box-shadow: 0 0 0 3px rgba(124, 156, 255, 0.15); +} + +input:disabled, +button:disabled { + cursor: wait; + opacity: 0.68; +} + +button { + min-height: 3.2rem; + margin-top: 0.25rem; + border: 0; + border-radius: 0.78rem; + color: #fff; + background: linear-gradient(135deg, #7c9cff, #5f7ff0); + box-shadow: 0 0.7rem 1.8rem rgba(68, 96, 202, 0.22); + font: inherit; + font-weight: 700; + cursor: pointer; + transition: + transform 120ms ease, + filter 120ms ease; +} + +button:hover:not(:disabled) { + filter: brightness(1.07); + transform: translateY(-1px); +} + +button:active:not(:disabled) { + transform: translateY(0); +} + +button:focus-visible { + outline: 3px solid rgba(124, 156, 255, 0.35); + outline-offset: 2px; +} + +.login-error { + margin: 0; + padding: 0.78rem 0.9rem; + border: 1px solid rgba(255, 116, 116, 0.24); + border-radius: 0.7rem; + color: #ffb4b4; + background: rgba(130, 37, 45, 0.13); + font-size: 0.82rem; + line-height: 1.5; +} + +.login-status { + min-height: 1.25rem; + margin: 1.1rem 0 0; + color: #9da7bc; + font-size: 0.76rem; + text-align: center; +} + +.login-footer { + margin: 1.25rem 0 0; + color: #727d93; + font-size: 0.72rem; + line-height: 1.5; + text-align: center; +} + +@media (max-width: 420px) { + .login-page { + padding: 1.25rem 0.9rem; + } + + .login-card { + border-radius: 1.05rem; + } +} + +@media (prefers-reduced-motion: reduce) { + input, + button { + transition: none; + } +} diff --git a/ui/src/platformclaw/login.test.ts b/ui/src/platformclaw/login.test.ts new file mode 100644 index 00000000000..79c462e5f8f --- /dev/null +++ b/ui/src/platformclaw/login.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PlatformClawLoginController } from "./login.ts"; +import { PLATFORMCLAW_LOGIN_API_PATH, PLATFORMCLAW_SESSION_API_PATH } from "./web-contract.ts"; + +const fixtureValue = "fixture"; + +function fixture(): HTMLElement { + const root = document.createElement("main"); + root.innerHTML = ` +
+ + + + +
+

`; + document.body.append(root); + return root; +} + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +async function start(fetchImpl: ReturnType, timeout?: number) { + const root = fixture(); + const navigate = vi.fn(); + new PlatformClawLoginController(root, { + fetchImpl, + navigate, + location: window.location, + sessionCheckTimeoutMs: timeout, + }).start(); + await vi.waitFor(() => expect(root.querySelector("button")?.textContent).toBe("로그인")); + return { root, navigate }; +} + +function submit(root: ParentNode): void { + const identifier = root.querySelector('input[name="identifier"]'); + const secondInput = root.querySelector('input[name="password"]'); + const form = root.querySelector("form"); + if (!identifier || !secondInput || !form) { + throw new Error("login fixture missing"); + } + identifier.value = "person.one"; + secondInput.value = fixtureValue; + form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); +} + +describe("PlatformClawLoginController", () => { + beforeEach(() => window.history.replaceState({}, "", "/platformclaw/login")); + afterEach(() => document.body.replaceChildren()); + + it("checks the opaque session before enabling sign-in", async () => { + const fetchImpl = vi.fn(async () => response({ authenticated: false })); + const { root } = await start(fetchImpl); + expect(fetchImpl).toHaveBeenCalledWith( + PLATFORMCLAW_SESSION_API_PATH, + expect.objectContaining({ credentials: "include", signal: expect.any(AbortSignal) }), + ); + expect(root.querySelector('input[name="identifier"]')?.disabled).toBe(false); + }); + + it("enables sign-in when session bootstrap stalls", async () => { + const fetchImpl = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit): Promise => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted")), { + once: true, + }); + }), + ); + const { root } = await start(fetchImpl, 5); + expect(root.querySelector("button")?.textContent).toBe("로그인"); + }); + + it("clears the secret field after rejected credentials", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response({ authenticated: false })) + .mockResolvedValueOnce(response({ authenticated: false }, 401)); + const { root, navigate } = await start(fetchImpl); + submit(root); + await vi.waitFor(() => + expect(root.querySelector('[role="alert"]')?.textContent).toContain("아이디"), + ); + expect(fetchImpl).toHaveBeenLastCalledWith( + PLATFORMCLAW_LOGIN_API_PATH, + expect.objectContaining({ method: "POST", credentials: "include" }), + ); + expect(root.querySelector('input[name="password"]')?.value).toBe(""); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("enters the sanitized application return route", async () => { + window.history.replaceState( + {}, + "", + "/platformclaw/login?returnTo=%2Fplatformclaw%2Fapp%2Fsessions", + ); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response({ authenticated: false })) + .mockResolvedValueOnce(response({ authenticated: true })); + const { root, navigate } = await start(fetchImpl); + submit(root); + await vi.waitFor(() => expect(navigate).toHaveBeenCalledWith("/platformclaw/app/sessions")); + }); +}); diff --git a/ui/src/platformclaw/login.ts b/ui/src/platformclaw/login.ts new file mode 100644 index 00000000000..e5047bb0ca4 --- /dev/null +++ b/ui/src/platformclaw/login.ts @@ -0,0 +1,177 @@ +import { + PLATFORMCLAW_LOGIN_API_PATH, + PLATFORMCLAW_SESSION_API_PATH, + resolvePlatformClawReturnTo, +} from "./web-contract.ts"; + +const SESSION_CHECK_TIMEOUT_MS = 5_000; +type LoginPhase = "checking" | "ready" | "submitting"; + +type LoginElements = { + form: HTMLFormElement; + identifier: HTMLInputElement; + secretInput: HTMLInputElement; + submit: HTMLButtonElement; + error: HTMLElement; + status: HTMLElement; +}; + +export type PlatformClawLoginOptions = { + fetchImpl?: typeof fetch; + location?: Pick; + navigate?: (path: string) => void; + sessionCheckTimeoutMs?: number; +}; + +function requiredElement(root: ParentNode, selector: string): T { + const element = root.querySelector(selector); + if (!element) { + throw new Error(`PlatformClaw login element missing: ${selector}`); + } + return element; +} + +function readElements(root: ParentNode): LoginElements { + return { + form: requiredElement(root, "[data-login-form]"), + identifier: requiredElement(root, 'input[name="identifier"]'), + secretInput: requiredElement(root, 'input[name="password"]'), + submit: requiredElement(root, 'button[type="submit"]'), + error: requiredElement(root, "[data-login-error]"), + status: requiredElement(root, "[data-login-status]"), + }; +} + +function isAuthenticated(payload: unknown): boolean { + return Boolean( + payload && + typeof payload === "object" && + "authenticated" in payload && + payload.authenticated === true, + ); +} + +function readMessage(payload: unknown): string | null { + if (!payload || typeof payload !== "object" || !("message" in payload)) { + return null; + } + return typeof payload.message === "string" ? payload.message.trim() || null : null; +} + +function loginFailureMessage(status: number, payload: unknown): string { + if (status === 401) { + return "아이디 또는 비밀번호를 확인해 주세요."; + } + if (status === 429) { + return "로그인 시도가 많습니다. 잠시 후 다시 시도해 주세요."; + } + if (status === 403) { + return "현재 계정으로는 접속할 수 없습니다. 관리자에게 문의해 주세요."; + } + if (status === 409) { + return "사용 가능한 로그인 세션 수를 초과했습니다. 관리자에게 문의해 주세요."; + } + return readMessage(payload) ?? "로그인하지 못했습니다. 잠시 후 다시 시도해 주세요."; +} + +export class PlatformClawLoginController { + private readonly elements: LoginElements; + private readonly fetchImpl: typeof fetch; + private readonly returnTo: string; + private readonly navigate: (path: string) => void; + private readonly sessionCheckTimeoutMs: number; + private phase: LoginPhase = "checking"; + + constructor(root: ParentNode, options: PlatformClawLoginOptions = {}) { + this.elements = readElements(root); + this.fetchImpl = options.fetchImpl ?? fetch; + const location = options.location ?? window.location; + this.returnTo = resolvePlatformClawReturnTo(location); + this.navigate = options.navigate ?? ((path) => window.location.assign(path)); + this.sessionCheckTimeoutMs = options.sessionCheckTimeoutMs ?? SESSION_CHECK_TIMEOUT_MS; + } + + start(): void { + this.elements.form.addEventListener("submit", (event) => { + void this.submit(event); + }); + this.render(); + void this.checkSession(); + } + + private render(error?: string): void { + const busy = this.phase !== "ready"; + this.elements.identifier.disabled = busy; + this.elements.secretInput.disabled = busy; + this.elements.submit.disabled = busy; + this.elements.submit.textContent = + this.phase === "checking" + ? "세션 확인 중…" + : this.phase === "submitting" + ? "로그인 중…" + : "로그인"; + this.elements.status.textContent = + this.phase === "checking" ? "기존 로그인 상태를 확인하고 있습니다." : ""; + this.elements.error.hidden = !error; + this.elements.error.textContent = error ?? ""; + } + + private async checkSession(): Promise { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), this.sessionCheckTimeoutMs); + try { + const response = await this.fetchImpl(PLATFORMCLAW_SESSION_API_PATH, { + headers: { Accept: "application/json" }, + credentials: "include", + signal: controller.signal, + }); + if (response.ok && isAuthenticated(await response.json())) { + this.navigate(this.returnTo); + return; + } + } catch { + // A temporary bootstrap failure must not prevent a fresh sign-in attempt. + } finally { + window.clearTimeout(timeout); + } + this.phase = "ready"; + this.render(); + } + + private async submit(event: SubmitEvent): Promise { + event.preventDefault(); + if (this.phase !== "ready" || !this.elements.form.reportValidity()) { + return; + } + this.phase = "submitting"; + this.render(); + try { + const response = await this.fetchImpl(PLATFORMCLAW_LOGIN_API_PATH, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + identifier: this.elements.identifier.value.trim(), + password: this.elements.secretInput.value, + }), + }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok || !isAuthenticated(payload)) { + this.elements.secretInput.value = ""; + this.elements.secretInput.focus(); + this.phase = "ready"; + this.render(loginFailureMessage(response.status, payload)); + return; + } + this.navigate(this.returnTo); + } catch { + this.phase = "ready"; + this.render("로그인 서버에 연결할 수 없습니다. 네트워크 상태를 확인해 주세요."); + } + } +} + +const loginRoot = document.querySelector("[data-platformclaw-login]"); +if (loginRoot) { + new PlatformClawLoginController(loginRoot).start(); +} diff --git a/ui/src/platformclaw/vite-login-path.node.test.ts b/ui/src/platformclaw/vite-login-path.node.test.ts new file mode 100644 index 00000000000..8a9403dfdfd --- /dev/null +++ b/ui/src/platformclaw/vite-login-path.node.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { rewritePlatformClawDevLoginUrl } from "../../vite.config.ts"; + +describe("rewritePlatformClawDevLoginUrl", () => { + it("maps the public login contract to the Vite HTML entry", () => { + expect( + rewritePlatformClawDevLoginUrl("/platformclaw/login?returnTo=%2Fplatformclaw%2Fapp"), + ).toBe("/platformclaw-login.html?returnTo=%2Fplatformclaw%2Fapp"); + }); + + it("does not rewrite sibling application or API routes", () => { + expect(rewritePlatformClawDevLoginUrl("/platformclaw/app/chat")).toBe("/platformclaw/app/chat"); + expect(rewritePlatformClawDevLoginUrl("/platformclaw/api/auth/session")).toBe( + "/platformclaw/api/auth/session", + ); + }); +}); diff --git a/ui/src/platformclaw/web-contract.test.ts b/ui/src/platformclaw/web-contract.test.ts new file mode 100644 index 00000000000..cf63d72e77a --- /dev/null +++ b/ui/src/platformclaw/web-contract.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { PLATFORMCLAW_DEFAULT_APP_PATH, resolvePlatformClawReturnTo } from "./web-contract.ts"; + +function locationFor(returnTo?: string): Pick { + const url = new URL("https://platformclaw.example/platformclaw/login"); + if (returnTo !== undefined) { + url.searchParams.set("returnTo", returnTo); + } + return { href: url.href, origin: url.origin }; +} + +describe("resolvePlatformClawReturnTo", () => { + it("accepts only same-origin PlatformClaw application routes", () => { + expect( + resolvePlatformClawReturnTo(locationFor("/platformclaw/app/sessions?q=active#top")), + ).toBe("/platformclaw/app/sessions?q=active#top"); + expect( + resolvePlatformClawReturnTo( + locationFor("https://platformclaw.example/platformclaw/app/chat"), + ), + ).toBe("/platformclaw/app/chat"); + }); + + it.each([ + undefined, + "https://evil.example/platformclaw/app/chat", + "//evil.example/platformclaw/app/chat", + "/platformclaw/api/auth/session", + "/platformclaw/application/chat", + "/platformclaw/app\\..\\login", + "not a valid app route", + ])("falls back for unsafe returnTo %s", (returnTo) => { + expect(resolvePlatformClawReturnTo(locationFor(returnTo))).toBe(PLATFORMCLAW_DEFAULT_APP_PATH); + }); +}); diff --git a/ui/src/platformclaw/web-contract.ts b/ui/src/platformclaw/web-contract.ts new file mode 100644 index 00000000000..8a5485b1acb --- /dev/null +++ b/ui/src/platformclaw/web-contract.ts @@ -0,0 +1,47 @@ +export const PLATFORMCLAW_LOGIN_PATH = "/platformclaw/login"; +export const PLATFORMCLAW_APP_PATH = "/platformclaw/app"; +export const PLATFORMCLAW_DEFAULT_APP_PATH = `${PLATFORMCLAW_APP_PATH}/chat`; +export const PLATFORMCLAW_LOGIN_API_PATH = "/platformclaw/api/auth/login"; +export const PLATFORMCLAW_LOGOUT_API_PATH = "/platformclaw/api/auth/logout"; +export const PLATFORMCLAW_SESSION_API_PATH = "/platformclaw/api/auth/session"; +export const PLATFORMCLAW_GATEWAY_PATH = "/platformclaw/gateway"; + +export const PLATFORMCLAW_ENABLED_ROUTES = ["chat", "new-session", "sessions"] as const; + +export type PlatformClawWebDescriptor = { + mode: "platformclaw"; + gatewayPath: typeof PLATFORMCLAW_GATEWAY_PATH; + loginPath: typeof PLATFORMCLAW_LOGIN_PATH; + logoutPath: typeof PLATFORMCLAW_LOGOUT_API_PATH; + sessionPath: typeof PLATFORMCLAW_SESSION_API_PATH; + enabledRoutes: typeof PLATFORMCLAW_ENABLED_ROUTES; +}; + +export const PLATFORMCLAW_WEB_DESCRIPTOR: PlatformClawWebDescriptor = { + mode: "platformclaw", + gatewayPath: PLATFORMCLAW_GATEWAY_PATH, + loginPath: PLATFORMCLAW_LOGIN_PATH, + logoutPath: PLATFORMCLAW_LOGOUT_API_PATH, + sessionPath: PLATFORMCLAW_SESSION_API_PATH, + enabledRoutes: PLATFORMCLAW_ENABLED_ROUTES, +}; + +export function resolvePlatformClawReturnTo(location: Pick): string { + const current = new URL(location.href); + const value = current.searchParams.get("returnTo"); + if (!value || value.includes("\\")) { + return PLATFORMCLAW_DEFAULT_APP_PATH; + } + try { + const target = new URL(value, location.origin); + const isAppPath = + target.pathname === PLATFORMCLAW_APP_PATH || + target.pathname.startsWith(`${PLATFORMCLAW_APP_PATH}/`); + if (target.origin !== location.origin || !isAppPath) { + return PLATFORMCLAW_DEFAULT_APP_PATH; + } + return `${target.pathname}${target.search}${target.hash}`; + } catch { + return PLATFORMCLAW_DEFAULT_APP_PATH; + } +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 61af369b332..e39e843b1f7 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -87,6 +87,11 @@ function normalizeBase(input: string): string { return `${trimmed}/`; } +export function rewritePlatformClawDevLoginUrl(rawUrl: string | undefined): string | undefined { + const url = new URL(rawUrl ?? "/", "http://localhost"); + return url.pathname === "/platformclaw/login" ? `/platformclaw-login.html${url.search}` : rawUrl; +} + function readPackageVersion(): string | null { try { const raw = fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"); @@ -445,6 +450,10 @@ export default function controlUiViteConfig(): UserConfig { { name: "control-ui-dev-stubs", configureServer(server) { + server.middlewares.use((req, _res, next) => { + req.url = rewritePlatformClawDevLoginUrl(req.url); + next(); + }); server.middlewares.use(bootstrapConfigPath, (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end( diff --git a/ui/vite.platformclaw-login.config.ts b/ui/vite.platformclaw-login.config.ts new file mode 100644 index 00000000000..a2b9228eba5 --- /dev/null +++ b/ui/vite.platformclaw-login.config.ts @@ -0,0 +1,18 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + base: "./", + publicDir: false, + build: { + outDir: path.resolve(here, "../dist/control-ui"), + emptyOutDir: false, + sourcemap: true, + rolldownOptions: { + input: path.resolve(here, "platformclaw-login.html"), + }, + }, +}); From 0bfe0763fc773cea15213dbcbc75799a2867ddf3 Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 07:53:17 +0900 Subject: [PATCH 4/6] feat(platformclaw): apply employee login design --- ui/platformclaw-login.html | 47 ++-- ui/src/platformclaw/favicon-employee.svg | 3 + ui/src/platformclaw/login.css | 282 +++++++++++++++-------- 3 files changed, 219 insertions(+), 113 deletions(-) create mode 100644 ui/src/platformclaw/favicon-employee.svg diff --git a/ui/platformclaw-login.html b/ui/platformclaw-login.html index ae3fed36c4e..fc20ffaadff 100644 --- a/ui/platformclaw-login.html +++ b/ui/platformclaw-login.html @@ -3,58 +3,71 @@ - + + PlatformClaw 로그인 +
- + + -
diff --git a/ui/src/platformclaw/favicon-employee.svg b/ui/src/platformclaw/favicon-employee.svg new file mode 100644 index 00000000000..063c076946c --- /dev/null +++ b/ui/src/platformclaw/favicon-employee.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/src/platformclaw/login.css b/ui/src/platformclaw/login.css index ab8c7300947..b9904d5d210 100644 --- a/ui/src/platformclaw/login.css +++ b/ui/src/platformclaw/login.css @@ -1,5 +1,5 @@ :root { - color-scheme: dark; + color-scheme: light; font-family: Inter, Pretendard, @@ -8,8 +8,9 @@ BlinkMacSystemFont, "Segoe UI", sans-serif; - color: #f6f7fb; - background: #090b11; + color: #141413; + background: #faf9f5; + font-synthesis: none; } * { @@ -25,72 +26,128 @@ body { body { display: grid; min-height: 100dvh; - place-items: center; overflow-x: hidden; background: - radial-gradient(circle at 16% 12%, rgba(88, 115, 220, 0.2), transparent 34rem), - radial-gradient(circle at 92% 88%, rgba(77, 183, 183, 0.11), transparent 30rem), #090b11; + radial-gradient(circle at 8% 12%, rgba(15, 124, 114, 0.08), transparent 32rem), #faf9f5; +} + +button, +input { + font: inherit; } .login-page { - width: min(100%, 31rem); - padding: 2rem 1.25rem; + display: grid; + width: min(100% - 3rem, 72rem); + margin: auto; + padding: 4rem 0; + align-items: center; + grid-template-columns: minmax(0, 1fr) minmax(24rem, 29rem); + gap: clamp(4rem, 9vw, 8.5rem); } -.login-brand { - display: flex; +.login-story { + padding: 1rem 0; +} + +.login-eyebrow { + display: inline-flex; + min-height: 2rem; + margin: 0 0 2rem; + padding: 0 0.85rem; align-items: center; - gap: 0.8rem; - margin: 0 0 1.5rem 0.25rem; + border: 1px solid #d8e5e1; + border-radius: 9999px; + color: #0f7c72; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; } -.login-mark { - display: grid; - width: 2.4rem; - height: 2.4rem; - place-items: center; - border: 1px solid rgba(151, 174, 255, 0.35); - border-radius: 0.8rem; - color: #dce4ff; - background: linear-gradient(145deg, rgba(124, 156, 255, 0.28), rgba(71, 94, 176, 0.08)); - box-shadow: 0 0 28px rgba(90, 122, 235, 0.16); - font-weight: 750; - letter-spacing: -0.06em; +.login-story h1 { + margin: 0; + max-width: 42rem; + font-family: + "Cormorant Garamond", "EB Garamond", "Noto Serif KR", "Nanum Myeongjo", Batang, Garamond, + "Times New Roman", serif; + font-size: clamp(3.4rem, 5vw, 4.5rem); + font-weight: 500; + letter-spacing: -0.045em; + line-height: 0.98; + text-wrap: balance; +} + +.login-story__intro { + margin: 2rem 0 0; + max-width: 36rem; + color: #3d3d3a; + font-size: clamp(1rem, 1.5vw, 1.12rem); + line-height: 1.7; } -.login-brand__name { - font-size: 1.02rem; - font-weight: 680; - letter-spacing: -0.02em; +.login-capabilities { + display: flex; + margin: 2rem 0 0; + padding: 0; + flex-wrap: wrap; + gap: 0.6rem; + list-style: none; } -.login-brand__label { - margin-top: 0.12rem; - color: #9da7bc; - font-size: 0.73rem; +.login-capabilities li { + padding: 0.52rem 0.85rem; + border-radius: 9999px; + color: #3d3d3a; + background: #efe9de; + font-size: 0.78rem; + font-weight: 500; } .login-card { - padding: clamp(1.5rem, 5vw, 2.25rem); - border: 1px solid rgba(160, 174, 207, 0.15); - border-radius: 1.35rem; - background: rgba(18, 21, 30, 0.88); - box-shadow: 0 1.5rem 5rem rgba(0, 0, 0, 0.36); - backdrop-filter: blur(18px); + padding: clamp(2rem, 4vw, 2.75rem); + border: 1px solid #e6dfd8; + border-radius: 1rem; + background: #f5f0e8; +} + +.login-card__brand { + display: grid; + width: 5rem; + height: 5rem; + margin: 0 auto 1.35rem; + place-items: center; + border-radius: 1rem; + background: #e8e0d2; +} + +.login-card__brand img { + display: block; + width: 3.6rem; + height: 3.6rem; +} + +.login-card__heading { + margin-bottom: 2rem; + text-align: center; } -h1 { +.login-card__heading h2 { margin: 0; - font-size: clamp(1.65rem, 6vw, 2rem); - line-height: 1.25; - letter-spacing: -0.045em; + font-family: + "Cormorant Garamond", "EB Garamond", "Noto Serif KR", "Nanum Myeongjo", Batang, Garamond, + "Times New Roman", serif; + font-size: 2rem; + font-weight: 500; + letter-spacing: -0.025em; + line-height: 1.2; } -.login-intro { - margin: 0.65rem 0 1.8rem; - color: #9da7bc; - font-size: 0.93rem; - line-height: 1.6; +.login-card__heading p { + margin: 0.5rem 0 0; + color: #6c6a64; + font-size: 0.85rem; + line-height: 1.55; } form { @@ -101,107 +158,140 @@ form { label { display: grid; gap: 0.5rem; - color: #cbd1df; - font-size: 0.82rem; - font-weight: 620; + color: #3d3d3a; + font-size: 0.8rem; + font-weight: 500; } input { width: 100%; - min-height: 3.15rem; - padding: 0 0.95rem; - border: 1px solid rgba(160, 174, 207, 0.2); - border-radius: 0.78rem; + min-height: 2.75rem; + padding: 0 0.9rem; + border: 1px solid #d7d0c7; + border-radius: 0.5rem; outline: none; - color: #f6f7fb; - background: rgba(8, 10, 16, 0.72); - font: inherit; + color: #141413; + background: #faf9f5; transition: - border-color 140ms ease, - box-shadow 140ms ease, - background 140ms ease; + border-color 120ms ease, + box-shadow 120ms ease; } -input:hover { - border-color: rgba(160, 174, 207, 0.38); +input::placeholder { + color: #8e8b82; } input:focus { - border-color: #7c9cff; - background: #0b0e16; - box-shadow: 0 0 0 3px rgba(124, 156, 255, 0.15); + border-color: #0f7c72; + box-shadow: 0 0 0 3px rgba(15, 124, 114, 0.15); } input:disabled, button:disabled { cursor: wait; - opacity: 0.68; + opacity: 0.62; } button { - min-height: 3.2rem; + min-height: 2.75rem; margin-top: 0.25rem; border: 0; - border-radius: 0.78rem; + border-radius: 0.5rem; color: #fff; - background: linear-gradient(135deg, #7c9cff, #5f7ff0); - box-shadow: 0 0.7rem 1.8rem rgba(68, 96, 202, 0.22); - font: inherit; - font-weight: 700; + background: #0f7c72; + font-size: 0.875rem; + font-weight: 600; cursor: pointer; - transition: - transform 120ms ease, - filter 120ms ease; -} - -button:hover:not(:disabled) { - filter: brightness(1.07); - transform: translateY(-1px); + transition: background-color 120ms ease; } button:active:not(:disabled) { - transform: translateY(0); + background: #0b625a; } button:focus-visible { - outline: 3px solid rgba(124, 156, 255, 0.35); + outline: 3px solid rgba(15, 124, 114, 0.25); outline-offset: 2px; } .login-error { margin: 0; - padding: 0.78rem 0.9rem; - border: 1px solid rgba(255, 116, 116, 0.24); - border-radius: 0.7rem; - color: #ffb4b4; - background: rgba(130, 37, 45, 0.13); - font-size: 0.82rem; + padding: 0.72rem 0.8rem; + border: 1px solid rgba(198, 69, 69, 0.24); + border-radius: 0.5rem; + color: #8d3030; + background: rgba(198, 69, 69, 0.07); + font-size: 0.8rem; line-height: 1.5; } .login-status { min-height: 1.25rem; - margin: 1.1rem 0 0; - color: #9da7bc; - font-size: 0.76rem; + margin: 1rem 0 0; + color: #6c6a64; + font-size: 0.75rem; text-align: center; } -.login-footer { - margin: 1.25rem 0 0; - color: #727d93; +.login-privacy { + margin: 1.15rem 0 0; + padding-top: 1.15rem; + border-top: 1px solid #e6dfd8; + color: #8e8b82; font-size: 0.72rem; line-height: 1.5; text-align: center; } +@media (max-width: 1050px) and (min-width: 801px) { + .login-page { + grid-template-columns: minmax(0, 1fr) minmax(22rem, 24rem); + gap: 3rem; + } + + .login-story h1 { + font-size: 3.4rem; + } +} + +@media (max-width: 800px) { + .login-page { + width: min(100% - 2rem, 31rem); + padding: 2.5rem 0; + grid-template-columns: 1fr; + gap: 2.75rem; + } + + .login-story { + padding: 0; + } + + .login-eyebrow { + margin-bottom: 1.35rem; + } + + .login-story h1 { + font-size: clamp(3rem, 13vw, 4.2rem); + } + + .login-story__intro { + margin-top: 1.4rem; + } +} + @media (max-width: 420px) { .login-page { - padding: 1.25rem 0.9rem; + width: min(100% - 1.5rem, 31rem); + padding: 1.5rem 0; + gap: 2.25rem; + } + + .desktop-only { + display: none; } .login-card { - border-radius: 1.05rem; + padding: 1.6rem 1.25rem; } } From 963665e2387f61404ef31b8ac926ac2d8144257e Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 09:26:27 +0900 Subject: [PATCH 5/6] fix(platformclaw): satisfy login UI lint --- ui/src/platformclaw/login.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/ui/src/platformclaw/login.ts b/ui/src/platformclaw/login.ts index e5047bb0ca4..298959217fd 100644 --- a/ui/src/platformclaw/login.ts +++ b/ui/src/platformclaw/login.ts @@ -23,9 +23,13 @@ export type PlatformClawLoginOptions = { sessionCheckTimeoutMs?: number; }; -function requiredElement(root: ParentNode, selector: string): T { - const element = root.querySelector(selector); - if (!element) { +function requiredElement( + root: ParentNode, + selector: string, + constructor: new () => T, +): T { + const element = root.querySelector(selector); + if (!(element instanceof constructor)) { throw new Error(`PlatformClaw login element missing: ${selector}`); } return element; @@ -33,12 +37,12 @@ function requiredElement(root: ParentNode, selector: string): function readElements(root: ParentNode): LoginElements { return { - form: requiredElement(root, "[data-login-form]"), - identifier: requiredElement(root, 'input[name="identifier"]'), - secretInput: requiredElement(root, 'input[name="password"]'), - submit: requiredElement(root, 'button[type="submit"]'), - error: requiredElement(root, "[data-login-error]"), - status: requiredElement(root, "[data-login-status]"), + form: requiredElement(root, "[data-login-form]", HTMLFormElement), + identifier: requiredElement(root, 'input[name="identifier"]', HTMLInputElement), + secretInput: requiredElement(root, 'input[name="password"]', HTMLInputElement), + submit: requiredElement(root, 'button[type="submit"]', HTMLButtonElement), + error: requiredElement(root, "[data-login-error]", HTMLElement), + status: requiredElement(root, "[data-login-status]", HTMLElement), }; } From b1ba7f1165d0272102cd7761b901496ee13b380d Mon Sep 17 00:00:00 2001 From: devSejung Date: Tue, 21 Jul 2026 09:43:17 +0900 Subject: [PATCH 6/6] fix(platformclaw): type login fetch mocks --- ui/src/platformclaw/login.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/platformclaw/login.test.ts b/ui/src/platformclaw/login.test.ts index 79c462e5f8f..20162c2c56c 100644 --- a/ui/src/platformclaw/login.test.ts +++ b/ui/src/platformclaw/login.test.ts @@ -22,7 +22,7 @@ function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status }); } -async function start(fetchImpl: ReturnType, timeout?: number) { +async function start(fetchImpl: typeof fetch, timeout?: number) { const root = fixture(); const navigate = vi.fn(); new PlatformClawLoginController(root, { @@ -52,7 +52,7 @@ describe("PlatformClawLoginController", () => { afterEach(() => document.body.replaceChildren()); it("checks the opaque session before enabling sign-in", async () => { - const fetchImpl = vi.fn(async () => response({ authenticated: false })); + const fetchImpl = vi.fn(async () => response({ authenticated: false })); const { root } = await start(fetchImpl); expect(fetchImpl).toHaveBeenCalledWith( PLATFORMCLAW_SESSION_API_PATH, @@ -62,7 +62,7 @@ describe("PlatformClawLoginController", () => { }); it("enables sign-in when session bootstrap stalls", async () => { - const fetchImpl = vi.fn( + const fetchImpl = vi.fn( async (_input: RequestInfo | URL, init?: RequestInit): Promise => await new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted")), { @@ -76,7 +76,7 @@ describe("PlatformClawLoginController", () => { it("clears the secret field after rejected credentials", async () => { const fetchImpl = vi - .fn() + .fn() .mockResolvedValueOnce(response({ authenticated: false })) .mockResolvedValueOnce(response({ authenticated: false }, 401)); const { root, navigate } = await start(fetchImpl); @@ -99,7 +99,7 @@ describe("PlatformClawLoginController", () => { "/platformclaw/login?returnTo=%2Fplatformclaw%2Fapp%2Fsessions", ); const fetchImpl = vi - .fn() + .fn() .mockResolvedValueOnce(response({ authenticated: false })) .mockResolvedValueOnce(response({ authenticated: true })); const { root, navigate } = await start(fetchImpl);