diff --git a/apps/host-selfhost/src/setup-status.web.test.ts b/apps/host-selfhost/src/setup-status.web.test.ts index 25b0a92471..14c05367ec 100644 --- a/apps/host-selfhost/src/setup-status.web.test.ts +++ b/apps/host-selfhost/src/setup-status.web.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it, vi } from "@effect/vitest"; -import { SetupStatusError, fetchNeedsSetup } from "../web/setup-status"; +import { SetupStatusError, fetchSetupStatus } from "../web/setup-status"; afterEach(() => { vi.unstubAllGlobals(); }); -describe("fetchNeedsSetup", () => { - it("returns false when the server says setup is complete", async () => { +describe("fetchSetupStatus", () => { + it("keeps password login for servers without optional authentication fields", async () => { vi.stubGlobal( "fetch", async () => @@ -17,7 +17,30 @@ describe("fetchNeedsSetup", () => { }), ); - await expect(fetchNeedsSetup()).resolves.toBe(false); + await expect(fetchSetupStatus()).resolves.toEqual({ + needsSetup: false, + oidcEnabled: false, + oidcProviderName: "SSO", + passwordSignInEnabled: true, + }); + }); + + it("preserves the configured SSO provider and disabled passwords", async () => { + const status = { + needsSetup: false, + oidcEnabled: true, + oidcProviderName: "Example SSO", + passwordSignInEnabled: false, + }; + vi.stubGlobal("fetch", async () => Response.json(status)); + await expect(fetchSetupStatus()).resolves.toEqual(status); + }); + + it("does not render a login method from an invalid successful response", async () => { + vi.stubGlobal("fetch", async () => + Response.json({ needsSetup: false, passwordSignInEnabled: "false" }), + ); + await expect(fetchSetupStatus()).rejects.toBeInstanceOf(SetupStatusError); }); it("retries failed setup checks before surfacing an error", async () => { @@ -27,7 +50,7 @@ describe("fetchNeedsSetup", () => { return new Response("unavailable", { status: 503 }); }); - await expect(fetchNeedsSetup()).rejects.toBeInstanceOf(SetupStatusError); + await expect(fetchSetupStatus()).rejects.toBeInstanceOf(SetupStatusError); expect(calls).toBe(3); }); }); diff --git a/apps/host-selfhost/web/login.tsx b/apps/host-selfhost/web/login.tsx index 09334add6c..8f71eff130 100644 --- a/apps/host-selfhost/web/login.tsx +++ b/apps/host-selfhost/web/login.tsx @@ -18,7 +18,15 @@ import { postLoginTarget } from "../src/auth/return-to"; // There is no self-signup here: open registration is closed. New people join by // redeeming an invite — either the full /join/ link, or by entering the // code here ("Have an invite code?"), which forwards to the same join page. -export const LoginPage = () => { +export const LoginPage = ({ + oidcEnabled, + oidcProviderName, + passwordSignInEnabled, +}: { + readonly oidcEnabled: boolean; + readonly oidcProviderName: string; + readonly passwordSignInEnabled: boolean; +}) => { // Where to go after sign-in. The gate renders this page IN PLACE of the // requested route without navigating, so the live location is what carries a // deep link (`/connect/linear`) across sign-in — see `postLoginTarget`. @@ -93,58 +101,68 @@ export const LoginPage = () => { {mode === "signin" ? ( -
-
- - setEmail((e.target as HTMLInputElement).value)} - autoComplete="email" - required - /> -
-
- - setPassword((e.target as HTMLInputElement).value)} - autoComplete="current-password" - required - minLength={8} - /> -
- {error &&

{error}

} - - {ssoProviders.length > 0 && ( - <> -
-
- or -
+
+ {oidcEnabled && ( + + )} + {oidcEnabled && passwordSignInEnabled && ( +
+ or use an instance account +
+ )} + {passwordSignInEnabled && ( + +
+ + setEmail((e.target as HTMLInputElement).value)} + autoComplete="email" + required + /> +
+
+ + setPassword((e.target as HTMLInputElement).value)} + autoComplete="current-password" + required + minLength={8} + />
- {ssoProviders.map((provider) => ( - - ))} - + {error &&

{error}

} + + )} - + {ssoProviders.map((provider) => ( + + ))} + {error && !passwordSignInEnabled &&

{error}

} +
) : (
@@ -163,19 +181,21 @@ export const LoginPage = () => { )} -
- -
+ {passwordSignInEnabled && ( +
+ +
+ )}
); diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index c92b49a3a5..43eaa1738a 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -32,7 +32,7 @@ import { DevicePage } from "../chromeless/device-page"; import { McpConsentPage } from "../chromeless/mcp-consent-page"; import { LoginPage } from "../login"; import { SetupPage } from "../setup"; -import { fetchNeedsSetup } from "../setup-status"; +import { fetchSetupStatus, type SetupStatus } from "../setup-status"; // --------------------------------------------------------------------------- // Self-host root: the SHARED multiplayer composition with Better Auth as the @@ -119,16 +119,16 @@ function AuthGate({ children }: { children: ReactNode }) { // the server whether the instance still has zero members. const [setupStatus, setSetupStatus] = useState< | { state: "checking"; attempt: number } - | { state: "ready"; needsSetup: boolean } + | { state: "ready"; value: SetupStatus } | { state: "error"; attempt: number } >({ state: "checking", attempt: 0 }); useEffect(() => { if (auth.status !== "unauthenticated") return; let alive = true; setSetupStatus((current) => ({ state: "checking", attempt: current.attempt })); - void fetchNeedsSetup().then( + void fetchSetupStatus().then( (value) => { - if (alive) setSetupStatus({ state: "ready", needsSetup: value }); + if (alive) setSetupStatus({ state: "ready", value }); }, () => { if (alive) { @@ -159,7 +159,15 @@ function AuthGate({ children }: { children: ReactNode }) { /> ); } - return setupStatus.needsSetup ? : ; + return setupStatus.value.needsSetup && !setupStatus.value.oidcEnabled ? ( + + ) : ( + + ); } return <>{children}; } diff --git a/apps/host-selfhost/web/setup-status.ts b/apps/host-selfhost/web/setup-status.ts index 03576ec8b2..b2d677dab0 100644 --- a/apps/host-selfhost/web/setup-status.ts +++ b/apps/host-selfhost/web/setup-status.ts @@ -1,3 +1,5 @@ +import { Effect, Option, Schema } from "effect"; + // Pre-login check of whether the instance still needs first-run setup (its one // org has zero members). Read by the auth gate to choose the setup vs sign-in // screen. A plain same-origin fetch — the same boundary the /join + setup @@ -14,18 +16,31 @@ export class SetupStatusError extends Error { } } -export const fetchNeedsSetup = async (): Promise => { +const SetupStatusSchema = Schema.Struct({ + needsSetup: Schema.Boolean, + oidcEnabled: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false))), + oidcProviderName: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed("SSO"))), + passwordSignInEnabled: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(true))), +}); + +export type SetupStatus = typeof SetupStatusSchema.Type; +const decodeSetupStatus = Schema.decodeUnknownOption(SetupStatusSchema); + +export const fetchSetupStatus = async (): Promise => { for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) { const response = await fetch("/api/setup-status", { credentials: "same-origin" }).then( (r) => r, () => null, ); if (response?.ok) { - const data = (await response.json().then( - (d) => d, - () => ({}), - )) as { needsSetup?: boolean }; - return data.needsSetup === true; + const data: unknown = await response.json().then( + (d: unknown) => d, + () => null, + ); + const parsed = decodeSetupStatus(data); + if (Option.isSome(parsed)) { + return parsed.value; + } } if (attempt < retryDelaysMs.length - 1) await sleep(retryDelaysMs[attempt]); } diff --git a/e2e/selfhost/login-methods.test.ts b/e2e/selfhost/login-methods.test.ts new file mode 100644 index 0000000000..c930a68f10 --- /dev/null +++ b/e2e/selfhost/login-methods.test.ts @@ -0,0 +1,60 @@ +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +const LoginConfiguration = Schema.Struct({ + needsSetup: Schema.Boolean, + oidcEnabled: Schema.optional(Schema.Boolean), + oidcProviderName: Schema.optional(Schema.String), + passwordSignInEnabled: Schema.optional(Schema.Boolean), +}); + +const decodeLoginConfiguration = Schema.decodeUnknownSync(LoginConfiguration); + +scenario( + "Login · offers only the configured sign-in methods", + { timeout: 60_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const configuration = yield* Effect.promise(async () => { + const response = await fetch(new URL("/api/setup-status", target.baseUrl)); + expect(response.ok).toBe(true); + const data: unknown = await response.json(); + return decodeLoginConfiguration(data); + }); + expect(configuration.needsSetup && !configuration.oidcEnabled).toBe(false); + yield* browser.session({ label: "Anonymous visitor" }, async ({ page, step }) => { + await step("Open the instance without an authenticated session", async () => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByRole("heading", { name: "Sign in", exact: true }).waitFor(); + if (configuration.oidcEnabled) { + await page + .getByRole("button", { + name: `Continue with ${configuration.oidcProviderName ?? "SSO"}`, + exact: true, + }) + .waitFor(); + } + const expectedPasswords = configuration.passwordSignInEnabled === false ? 0 : 1; + expect(await page.getByLabel("Password", { exact: true }).count()).toBe(expectedPasswords); + expect(await page.getByLabel("Email", { exact: true }).count()).toBe(expectedPasswords); + expect(await page.getByRole("button", { name: "Have an invite code? Join" }).count()).toBe( + expectedPasswords, + ); + }); + await step("Check the same sign-in choices on a phone screen", async () => { + await page.setViewportSize({ width: 390, height: 844 }); + const button = page.getByRole("button", { + name: `Continue with ${configuration.oidcProviderName ?? "SSO"}`, + exact: true, + }); + expect(await button.isVisible()).toBe(configuration.oidcEnabled === true); + expect(await page.getByLabel("Password", { exact: true }).isVisible()).toBe( + configuration.passwordSignInEnabled !== false, + ); + }); + }); + }), +);