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
33 changes: 28 additions & 5 deletions apps/host-selfhost/src/setup-status.web.test.ts
Original file line number Diff line number Diff line change
@@ -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 () =>
Expand All @@ -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 () => {
Expand All @@ -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);
});
});
148 changes: 84 additions & 64 deletions apps/host-selfhost/web/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code> 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`.
Expand Down Expand Up @@ -93,58 +101,68 @@ export const LoginPage = () => {
</div>

{mode === "signin" ? (
<form onSubmit={signIn} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail((e.target as HTMLInputElement).value)}
autoComplete="email"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword((e.target as HTMLInputElement).value)}
autoComplete="current-password"
required
minLength={8}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={busy} className="w-full">
{busy ? "…" : "Sign in"}
</Button>
{ssoProviders.length > 0 && (
<>
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border" />
<div className="space-y-4">
{oidcEnabled && (
<Button
type="button"
disabled={busy}
className="w-full"
onClick={() => void signInWithSso("executor-oidc")}
>
{busy ? "…" : `Continue with ${oidcProviderName}`}
</Button>
)}
{oidcEnabled && passwordSignInEnabled && (
<div className="text-center text-xs uppercase tracking-wide text-muted-foreground">
or use an instance account
</div>
)}
{passwordSignInEnabled && (
<form onSubmit={signIn} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail((e.target as HTMLInputElement).value)}
autoComplete="email"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword((e.target as HTMLInputElement).value)}
autoComplete="current-password"
required
minLength={8}
/>
</div>
{ssoProviders.map((provider) => (
<Button
key={provider.id}
type="button"
variant="outline"
disabled={busy}
onClick={() => void signInWithSso(provider.id)}
className="w-full"
>
Continue with {provider.name}
</Button>
))}
</>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={busy} className="w-full">
{busy ? "…" : "Sign in"}
</Button>
</form>
)}
</form>
{ssoProviders.map((provider) => (
<Button
key={provider.id}
type="button"
disabled={busy}
className="w-full"
onClick={() => void signInWithSso(provider.id)}
>
Continue with {provider.name}
</Button>
))}
{error && !passwordSignInEnabled && <p className="text-sm text-destructive">{error}</p>}
</div>
) : (
<form onSubmit={redeem} className="space-y-4">
<div className="space-y-1.5">
Expand All @@ -163,19 +181,21 @@ export const LoginPage = () => {
</form>
)}

<div className="text-center">
<Button
type="button"
variant="ghost"
onClick={() => {
setMode(mode === "signin" ? "code" : "signin");
setError(null);
}}
className="text-sm font-normal text-muted-foreground hover:text-foreground"
>
{mode === "signin" ? "Have an invite code? Join" : "Already have an account? Sign in"}
</Button>
</div>
{passwordSignInEnabled && (
<div className="text-center">
<Button
type="button"
variant="ghost"
onClick={() => {
setMode(mode === "signin" ? "code" : "signin");
setError(null);
}}
className="text-sm font-normal text-muted-foreground hover:text-foreground"
>
{mode === "signin" ? "Have an invite code? Join" : "Already have an account? Sign in"}
</Button>
</div>
)}
</div>
</AuthLayout>
);
Expand Down
18 changes: 13 additions & 5 deletions apps/host-selfhost/web/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -159,7 +159,15 @@ function AuthGate({ children }: { children: ReactNode }) {
/>
);
}
return setupStatus.needsSetup ? <SetupPage /> : <LoginPage />;
return setupStatus.value.needsSetup && !setupStatus.value.oidcEnabled ? (
<SetupPage />
) : (
<LoginPage
oidcEnabled={setupStatus.value.oidcEnabled}
oidcProviderName={setupStatus.value.oidcProviderName}
passwordSignInEnabled={setupStatus.value.passwordSignInEnabled}
/>
);
}
return <>{children}</>;
}
Expand Down
27 changes: 21 additions & 6 deletions apps/host-selfhost/web/setup-status.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,18 +16,31 @@ export class SetupStatusError extends Error {
}
}

export const fetchNeedsSetup = async (): Promise<boolean> => {
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<SetupStatus> => {
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]);
}
Expand Down
60 changes: 60 additions & 0 deletions e2e/selfhost/login-methods.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
}),
);
Loading