Skip to content

Commit 8fe8dc4

Browse files
committed
Retry setup-status checks before showing auth
1 parent 747f4e1 commit 8fe8dc4

3 files changed

Lines changed: 122 additions & 20 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { afterEach, describe, expect, it, vi } from "@effect/vitest";
2+
3+
import { SetupStatusError, fetchNeedsSetup } from "../web/setup-status";
4+
5+
afterEach(() => {
6+
vi.unstubAllGlobals();
7+
});
8+
9+
describe("fetchNeedsSetup", () => {
10+
it("returns false when the server says setup is complete", async () => {
11+
vi.stubGlobal("fetch", async () =>
12+
new Response(JSON.stringify({ needsSetup: false }), {
13+
status: 200,
14+
headers: { "content-type": "application/json" },
15+
}),
16+
);
17+
18+
await expect(fetchNeedsSetup()).resolves.toBe(false);
19+
});
20+
21+
it("retries failed setup checks before surfacing an error", async () => {
22+
let calls = 0;
23+
vi.stubGlobal("fetch", async () => {
24+
calls += 1;
25+
return new Response("unavailable", { status: 503 });
26+
});
27+
28+
await expect(fetchNeedsSetup()).rejects.toBeInstanceOf(SetupStatusError);
29+
expect(calls).toBe(3);
30+
});
31+
});

apps/host-selfhost/web/routes/__root.tsx

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
66
import { OrganizationProvider } from "@executor-js/react/api/organization-context";
77
import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate";
88
import { Toaster } from "@executor-js/react/components/sonner";
9+
import { Button } from "@executor-js/react/components/button";
10+
import {
11+
Card,
12+
CardContent,
13+
CardDescription,
14+
CardHeader,
15+
CardTitle,
16+
} from "@executor-js/react/components/card";
917
import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";
1018
import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
1119
import { plugins as clientPlugins } from "virtual:executor/plugins-client";
@@ -48,26 +56,72 @@ const Loading = () => (
4856
</div>
4957
);
5058

59+
const SetupStatusErrorCard = ({ onRetry }: { onRetry: () => void }) => (
60+
<div className="flex min-h-screen items-center justify-center bg-background p-6">
61+
<Card className="w-full max-w-md">
62+
<CardHeader>
63+
<CardTitle>Can't reach the server</CardTitle>
64+
<CardDescription>
65+
Executor couldn't check this instance's setup state. Make sure the server is running, then
66+
retry.
67+
</CardDescription>
68+
</CardHeader>
69+
<CardContent>
70+
<Button type="button" onClick={onRetry}>
71+
Retry
72+
</Button>
73+
</CardContent>
74+
</Card>
75+
</div>
76+
);
77+
5178
function AuthGate({ children }: { children: ReactNode }) {
5279
const auth = useAuth();
5380
// When unauthenticated, decide between first-run setup and sign-in by asking
54-
// the server whether the instance still has zero members. `null` = checking.
55-
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
81+
// the server whether the instance still has zero members.
82+
const [setupStatus, setSetupStatus] = useState<
83+
| { state: "checking"; attempt: number }
84+
| { state: "ready"; needsSetup: boolean }
85+
| { state: "error"; attempt: number }
86+
>({ state: "checking", attempt: 0 });
5687
useEffect(() => {
5788
if (auth.status !== "unauthenticated") return;
5889
let alive = true;
59-
void fetchNeedsSetup().then((value) => {
60-
if (alive) setNeedsSetup(value);
61-
});
90+
setSetupStatus((current) => ({ state: "checking", attempt: current.attempt }));
91+
void fetchNeedsSetup().then(
92+
(value) => {
93+
if (alive) setSetupStatus({ state: "ready", needsSetup: value });
94+
},
95+
() => {
96+
if (alive) {
97+
setSetupStatus((current) => ({
98+
state: "error",
99+
attempt: current.state === "ready" ? 0 : current.attempt,
100+
}));
101+
}
102+
},
103+
);
62104
return () => {
63105
alive = false;
64106
};
65-
}, [auth.status]);
107+
}, [auth.status, setupStatus.attempt]);
66108

67109
if (auth.status === "loading") return <Loading />;
68110
if (auth.status === "unauthenticated") {
69-
if (needsSetup === null) return <Loading />;
70-
return needsSetup ? <SetupPage /> : <LoginPage />;
111+
if (setupStatus.state === "checking") return <Loading />;
112+
if (setupStatus.state === "error") {
113+
return (
114+
<SetupStatusErrorCard
115+
onRetry={() =>
116+
setSetupStatus((current) => ({
117+
state: "checking",
118+
attempt: current.state === "ready" ? 0 : current.attempt + 1,
119+
}))
120+
}
121+
/>
122+
);
123+
}
124+
return setupStatus.needsSetup ? <SetupPage /> : <LoginPage />;
71125
}
72126
return <>{children}</>;
73127
}
Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
11
// Pre-login check of whether the instance still needs first-run setup (its one
22
// org has zero members). Read by the auth gate to choose the setup vs sign-in
33
// screen. A plain same-origin fetch — the same boundary the /join + setup
4-
// screens use, which run before the atom registry exists. Two-arg `then` keeps
5-
// it Promise.catch-free; any failure falls back to "no setup needed" (sign-in).
4+
// screens use, which run before the atom registry exists.
5+
6+
const retryDelaysMs = [250, 500, 1_000] as const;
7+
8+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
9+
10+
export class SetupStatusError extends Error {
11+
constructor() {
12+
super("Unable to check setup status");
13+
this.name = "SetupStatusError";
14+
}
15+
}
16+
617
export const fetchNeedsSetup = async (): Promise<boolean> => {
7-
const response = await fetch("/api/setup-status", { credentials: "same-origin" }).then(
8-
(r) => r,
9-
() => null,
10-
);
11-
if (!response || !response.ok) return false;
12-
const data = (await response.json().then(
13-
(d) => d,
14-
() => ({}),
15-
)) as { needsSetup?: boolean };
16-
return data.needsSetup === true;
18+
for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) {
19+
const response = await fetch("/api/setup-status", { credentials: "same-origin" }).then(
20+
(r) => r,
21+
() => null,
22+
);
23+
if (response?.ok) {
24+
const data = (await response.json().then(
25+
(d) => d,
26+
() => ({}),
27+
)) as { needsSetup?: boolean };
28+
return data.needsSetup === true;
29+
}
30+
if (attempt < retryDelaysMs.length - 1) await sleep(retryDelaysMs[attempt]);
31+
}
32+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: pre-Effect setup-status fetch rejects so the auth gate can surface retry failure
33+
throw new SetupStatusError();
1734
};

0 commit comments

Comments
 (0)