Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -1750,10 +1750,10 @@ unchanged to Core, without borrowing the console's caller or administrator key.
The same origin, path, method and transport restrictions still apply.

Account mode is explicit (`CORE_CONSOLE_AUTH_MODE=account`) and requires a private
setup-key file and a private writable state directory. Only the installation's
one-time setup credential can claim the administrator account. The atomic durable
account record stores a password hash; corruption or a missing required credential
must never reopen registration. Account creation is race-safe. Cookie sessions are
writable state directory. The first visitor registers the sole administrator with
a username and password; no initialization key is required. The atomic durable
account record stores a password hash; corruption or missing registered account
state must never reopen registration. Account creation is race-safe. Cookie sessions are
bounded, HttpOnly, SameSite Strict and Secure for HTTPS origins; a restart requires
sign-in again, without deleting the account. Unauthenticated access is limited to
the static login UI, finite console authentication routes and the existing
Expand Down Expand Up @@ -1786,10 +1786,12 @@ access step. Web-only consoles with `api_keys: false` instead explain how to use
an existing Core key and allow the introduction to continue without key-management
requests. A failed or malformed capability read must not imply either capability.

First-run Home is a skippable/replayable console introduction after account setup.
It does not change public Core resource semantics or block ordinary administration.
First-run Home is a standalone full-screen, skippable/replayable tutorial after
account setup, outside the console shell. Setup and the introduction have no
sidebar. Respect reduced-motion preferences throughout. The introduction does not
change public Core resource semantics or block ordinary administration.
Keep new onboarding state and components outside the oversized `App.tsx`. Persist
only non-secret presentation progress; password, setup key and model provider key
only non-secret presentation progress; password and model provider key
must not enter browser storage or generated code samples. Creating a saved Agent
is an explicit write through the existing API. Reconcile uncertain results before
another write, and associate external examples with their exact metadata marker,
Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ existing Sessions retain their node across disconnects and resume.
This starts Core, Web and PostgreSQL with zero execution nodes. Public release
bundles are not published yet; see the [installation guide](docs/getting-started/install.md)
for building a bundle and the host/network prerequisites.
2. **Sign in to Web.** Open the console address printed by the installer. Use
the one-time key in `~/.parsar/core/config/console.setup.key` to register your
administrator account, then keep your chosen username and password safe.
2. **Sign in to Web.** Open the console address printed by the installer and
register your administrator account with a username and password. Keep them safe.
Existing installations retain their `admin` / `console.password` login.
The console connects to Core automatically. During first-run, create and save
an Agent API key for requests from your own machine or application.
Expand Down
41 changes: 33 additions & 8 deletions apps/web/e2e/first-run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@ test.beforeEach(async ({ page, request }) => {

test("validates administrator setup before sending credentials and keeps only nonsecret progress", async ({ page }) => {
const writes = await mockAccount(page, { mode: "setup" });
await page.setViewportSize({ width: 1440, height: 1000 });
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.goto("/");
await expect(page.getByRole("heading", { name: "Create your administrator account" })).toBeVisible();
await page.getByLabel(/^Setup key/).fill("fixture-setup-secret");
await expect(page.locator(".app-sidebar")).toHaveCount(0);
await expect(page.getByLabel(/^Setup key/)).toHaveCount(0);
await expect(page.getByText("PARSAR / CORE", { exact: true })).toHaveCount(0);
await expect(page.getByText("01 — You manage this cloud.", { exact: true })).toHaveCount(0);
await fillAccount(page);
await page.getByLabel("Confirm password", { exact: true }).fill("different-password");
await page.getByRole("button", { name: "Create administrator account", exact: true }).click();
Expand All @@ -63,16 +67,19 @@ test("validates administrator setup before sending credentials and keeps only no
await page.getByLabel("Confirm password", { exact: true }).fill(password);
await page.getByRole("button", { name: "Create administrator account", exact: true }).click();
await expect(page.getByRole("heading", { name: "Keep your sign-in details." })).toBeVisible();
expect(writes).toEqual([{ action: "setup", body: { username, password, setup_key: "fixture-setup-secret" } }]);
await expect(page.locator(".app-sidebar")).toHaveCount(0);
expect(writes).toEqual([{ action: "setup", body: { username, password } }]);
await expect(page.locator(".app-shell")).toHaveCount(0);
await expect(page.locator(".first-run-home")).toHaveCSS("width", "1440px");
await expect(page.locator(".first-run-home")).toHaveCSS("height", "1000px");
await expect(page.getByRole("button", { name: "Skip introduction", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Copy sign-in details", exact: true }).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toContain(new URL(page.url()).origin);
expect(copied).toContain(username);
expect(copied).not.toContain(password);
expect(copied).not.toContain("fixture-setup-secret");
const storage = await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }));
expect(storage).not.toContain(password);
expect(storage).not.toContain("fixture-setup-secret");
});

test("remembers the introduction step and dismissal across reloads and sign-in, and allows replay", async ({ page }) => {
Expand All @@ -86,6 +93,7 @@ test("remembers the introduction step and dismissal across reloads and sign-in,
await expect(page.getByRole("heading", { name: "Make your first API request." })).toBeVisible();
await page.getByRole("button", { name: "Skip introduction", exact: true }).first().click();
await expect(page.locator(".first-run-home")).toHaveCount(0);
await expect(page.locator(".app-sidebar")).toBeVisible();
await page.reload();
await expect(page.getByRole("button", { name: "Sign out", exact: true })).toBeVisible();
await expect(page.locator(".first-run-home")).toHaveCount(0);
Expand All @@ -101,6 +109,25 @@ test("remembers the introduction step and dismissal across reloads and sign-in,
expect(writes.map(({ action }) => action)).toEqual(["logout", "login"]);
});

test("keeps tutorial preferences inside the viewport and restores console navigation when skipped", async ({ page }) => {
await mockAccount(page, { mode: "authenticated", username });
await page.goto("/");
await page.locator(".first-run-toolbar .appearance-menu-trigger").click();
const menu = page.getByRole("menu");
const bounds = await menu.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.y).toBeGreaterThanOrEqual(0);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(page.viewportSize()!.height);
await page.getByRole("menuitemradio", { name: "Dark theme", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.locator(".first-run-toolbar .appearance-menu-trigger").click();
await page.getByRole("menuitemradio", { name: "简体中文", exact: true }).click();
await expect(page.getByRole("heading", { name: "保存你的登录信息。" })).toBeVisible();
await page.getByRole("button", { name: "跳过导览", exact: true }).click();
await expect(page.locator(".app-sidebar")).toBeVisible();
await expect(page.locator(".first-run-home")).toHaveCount(0);
});

test("keeps private Core requests unmounted on an authentication network failure", async ({ page, request }) => {
await page.route("**/console/auth", (route) => route.abort("failed"));
await page.goto("/");
Expand All @@ -124,7 +151,6 @@ test("does not replay an uncertain registration and reconciles the account befor
return route.abort("failed");
});
await page.goto("/");
await page.getByLabel(/^Setup key/).fill("fixture-setup-secret");
await fillAccount(page);
await page.getByLabel("Confirm password", { exact: true }).fill(password);
await page.getByRole("button", { name: "Create administrator account", exact: true }).click();
Expand All @@ -141,15 +167,14 @@ test("supports Chinese setup and reduced-motion introduction", async ({ page })
await page.emulateMedia({ reducedMotion: "reduce" });
await page.setViewportSize({ width: 1080, height: 900 });
await page.goto("/");
await page.getByRole("combobox", { name: "Console language" }).selectOption("zh");
await page.getByRole("combobox", { name: "Console language" }).selectOption("zh-CN");
await expect(page.getByRole("heading", { name: "创建管理员账户" })).toBeVisible();
await page.getByLabel(/^初始化密钥/).fill("fixture-setup-secret");
await page.getByLabel(/^管理员用户名/).fill(username);
await page.getByLabel(/^密码/).fill(password);
await page.getByLabel("确认密码", { exact: true }).fill(password);
await page.getByRole("button", { name: "创建管理员账户", exact: true }).click();
await expect(page.getByRole("heading", { name: "保存你的登录信息。" })).toBeVisible();
await expect(page.locator(".first-run-home")).toHaveAttribute("lang", "zh");
await expect(page.locator(".first-run-home")).toHaveAttribute("lang", "zh-CN");
const layout = await page.locator(".first-run-stage-content").evaluate((element) => ({
animation: getComputedStyle(element).animationName,
width: document.documentElement.scrollWidth,
Expand Down
17 changes: 8 additions & 9 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2281,7 +2281,13 @@ export function App() {
}, []);

return (
<SandboxProvider connection={connection}><div className="app-shell">
<SandboxProvider connection={connection}>{showIntroduction ? <FirstRunHome
key={`intro:${coreGeneration}`}
connection={connection} core={core} username={introduction.username}
initialStep={introduction.step} onStepChange={introduction.setStep}
onDone={introduction.dismiss} onRefresh={() => { void refreshAgents(); }}
onOpenAgent={(id) => { setIntroductionAgentId(id); void refreshAgents(); setView("agents"); }}
/> : <div className="app-shell">
<a className="skip-link" href="#main-content">{t("skipToContent")}</a>
<aside className="app-sidebar">
<div className="brand-lockup">
Expand Down Expand Up @@ -2346,13 +2352,6 @@ export function App() {
onConfigureConnection={() => setConnectionOpen(true)}
/>
) : null}
{showIntroduction ? <FirstRunHome
key={`intro:${coreGeneration}`}
connection={connection} core={core} username={introduction.username}
initialStep={introduction.step} onStepChange={introduction.setStep}
onDone={introduction.dismiss} onRefresh={() => { void refreshAgents(); }}
onOpenAgent={(id) => { setIntroductionAgentId(id); void refreshAgents(); setView("agents"); }}
/> : null}
<div className="cached-page-view" hidden={view !== "dashboard" || showIntroduction}>
<DashboardView
agents={agents}
Expand Down Expand Up @@ -2487,6 +2486,6 @@ export function App() {
onClose={() => setConnectionOpen(false)}
onSave={applyConnection}
/>
</div></SandboxProvider>
</div>}</SandboxProvider>
);
}
18 changes: 2 additions & 16 deletions apps/web/src/features/first-run/ConsoleAccess.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,23 +67,12 @@ export function ConsoleAccess({ children }: { children: ReactNode }) {
} }}>{children}</ConsoleAccountContext.Provider>;

return <div className="app-shell console-access">
<aside className="app-sidebar">
<div className="brand-lockup"><span className="brand-mark-frame">
<img className="brand-mark brand-mark-light" src="/parsar-mark-light.png" width="18" height="18" alt="" />
<img className="brand-mark brand-mark-dark" src="/parsar-mark-dark.png" width="18" height="18" alt="" />
</span><span className="brand-name">Agents Core Web</span></div>
<div className="console-access-nav"><Cloud size={15} aria-hidden="true" />{t("Your cloud. Your workspace.")}</div>
<div className="sidebar-footer"><ThemeMenu /></div>
</aside>
<main className="app-main console-access-main">
<header><ConsoleLanguage /></header>
<header><ThemeMenu /><ConsoleLanguage /></header>
<section className="console-access-stage">
<div className="console-access-story"><div className="console-cloud-symbol" aria-hidden="true"><Cloud size={34} strokeWidth={1} /></div>
<span className="console-eyebrow">PARSAR / CORE</span>
<h1>{t("A place for your Agents to work.")}</h1>
<p>{t("Connect your machines. Create Agents. Watch work happen.")}</p>
<div className="console-access-rule" aria-hidden="true" />
<span className="console-access-caption">01 — {t("You manage this cloud.")}</span>
</div>
{status && !failed ? <AccountForm key={`${status.mode}:${revision}`} setup={status.mode === "setup"} onAuthenticated={(next) => { generation.current++; setStatus(next); }} onRefresh={refresh} /> :
<div className="console-auth-form" aria-live="polite"><p>{t(failed ? "Could not connect to your console." : "Connecting to your console…")}</p>
Expand Down Expand Up @@ -116,7 +105,6 @@ function AccountForm({ setup, onAuthenticated, onRefresh }: {
try {
const next = await changeConsoleAuth(setup ? "setup" : "login", {
username: String(data.get("username") ?? ""), password,
...(setup ? { setup_key: String(data.get("setup_key") ?? "").trim() } : {}),
}, request.signal);
if (!request.signal.aborted) {
if (setup && next.mode === "authenticated") saveProgress(progressKey(window.location.origin, next.username), defaultProgress);
Expand All @@ -125,7 +113,7 @@ function AccountForm({ setup, onAuthenticated, onRefresh }: {
} catch (cause) {
if (request.signal.aborted) return;
const status = cause instanceof ConsoleAuthError ? cause.status : 0;
if (status === 401 || status === 400) setError(t(setup ? "Check the setup key and account details." : "Check your sign-in details and try again."));
if (status === 401 || status === 400) setError(t(setup ? "Check the account details and try again." : "Check your sign-in details and try again."));
else if (status === 429) setError(t("Too many attempts. Wait a moment before trying again."));
else {
setUncertain(true);
Expand All @@ -136,8 +124,6 @@ function AccountForm({ setup, onAuthenticated, onRefresh }: {
return <form className="console-auth-form form-stack" onSubmit={(event) => void submit(event)}>
<div><h2>{t(setup ? "Create your administrator account" : "Welcome back")}</h2>
<p>{t(setup ? "You manage this cloud." : "Use your administrator account to continue.")}</p></div>
{setup ? <label className="field"><span>{t("Setup key")}</span><input name="setup_key" type="password" autoComplete="off" required disabled={busy || uncertain} />
<small>{t("Find it in config/console.setup.key in your installation directory.")}</small></label> : null}
<label className="field"><span>{t("Administrator username")}</span><input name="username" autoComplete="username" autoCapitalize="none" spellCheck={false} required maxLength={64} pattern={setup ? "[a-zA-Z0-9._\\-]+" : undefined} disabled={busy || uncertain} />
{setup ? <small>{t("Letters, numbers, dots, underscores and hyphens.")}</small> : null}</label>
<label className="field"><span>{t("Password")}</span><input name="password" type="password" autoComplete={setup ? "new-password" : "current-password"} required disabled={busy || uncertain} />
Expand Down
Loading
Loading