Skip to content

Commit e977b28

Browse files
committed
Validate join invites before showing signup
1 parent b0037e9 commit e977b28

4 files changed

Lines changed: 84 additions & 1 deletion

File tree

apps/host-selfhost/src/admin/invites.node.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ const signUp = (body: Record<string, unknown>) =>
2727
}),
2828
);
2929

30+
const inviteStatus = async (code: string): Promise<boolean> => {
31+
const response = await handler(
32+
new Request(`${BASE}/api/invite-status/${encodeURIComponent(code)}`),
33+
);
34+
expect(response.status).toBe(200);
35+
const body = (await response.json()) as { valid?: boolean };
36+
return body.valid === true;
37+
};
38+
3039
test("open signup is closed: a signup without a valid invite code is rejected", async () => {
3140
const res = await signUp({
3241
email: "intruder@invite.test",
@@ -47,6 +56,8 @@ test("open signup is closed: a signup without a valid invite code is rejected",
4756
test("a code minted via the admin API redeems into a real org membership", async () => {
4857
// Minted through the TYPED admin HttpApi client (see mint-invite.ts).
4958
const inviteCode = await mintInviteCode(handler);
59+
expect(await inviteStatus("AAAA-BBBB-CCCC")).toBe(false);
60+
expect(await inviteStatus(inviteCode)).toBe(true);
5061

5162
const res = await signUp({
5263
email: "member@invite.test",
@@ -76,4 +87,5 @@ test("a code minted via the admin API redeems into a real org membership", async
7687
inviteCode,
7788
});
7889
expect(reuse.status).not.toBe(200);
90+
expect(await inviteStatus(inviteCode)).toBe(false);
7991
});

apps/host-selfhost/src/system/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export class SystemError extends Schema.TaggedErrorClass<SystemError>()(
2020

2121
export const HealthResponse = Schema.Struct({ status: Schema.String });
2222
export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean });
23+
export const InviteStatusResponse = Schema.Struct({ valid: Schema.Boolean });
24+
25+
const InviteStatusParams = { code: Schema.String };
2326

2427
export const SystemApi = HttpApiGroup.make("system")
2528
.add(
@@ -33,6 +36,13 @@ export const SystemApi = HttpApiGroup.make("system")
3336
success: SetupStatusResponse,
3437
error: [SystemError],
3538
}),
39+
)
40+
.add(
41+
HttpApiEndpoint.get("inviteStatus", "/invite-status/:code", {
42+
params: InviteStatusParams,
43+
success: InviteStatusResponse,
44+
error: [SystemError],
45+
}),
3646
);
3747

3848
export const SystemHttpApi = HttpApi.make("executor-self-host-system").add(SystemApi);

apps/host-selfhost/src/system/handlers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect";
55
import { SystemError, SystemHttpApi } from "./api";
66
import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth";
77
import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db";
8+
import { findRedeemableCode } from "../auth/invites";
89

910
// ---------------------------------------------------------------------------
1011
// Handlers for the public system API. Unauthenticated; every DB touch is an
@@ -38,6 +39,16 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han
3839
});
3940
return { needsSetup: count === 0 };
4041
}),
42+
)
43+
.handle("inviteStatus", ({ params }) =>
44+
Effect.gen(function* () {
45+
const { client } = yield* SelfHostDb;
46+
const code = yield* Effect.tryPromise({
47+
try: () => findRedeemableCode(client, params.code),
48+
catch: () => new SystemError({ message: "failed to read invite status" }),
49+
});
50+
return { valid: code !== null };
51+
}),
4152
),
4253
);
4354

apps/host-selfhost/web/routes/public/join.$code.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createFileRoute } from "@tanstack/react-router";
2-
import { useState, type FormEvent } from "react";
2+
import { useEffect, useState, type FormEvent } from "react";
33

44
import { Button } from "@executor-js/react/components/button";
5+
import { Card, CardDescription, CardHeader, CardTitle } from "@executor-js/react/components/card";
56
import { Input } from "@executor-js/react/components/input";
67
import { Label } from "@executor-js/react/components/label";
78

@@ -18,12 +19,37 @@ export const Route = createFileRoute("/join/$code")({
1819
// auth gate (an un-redeemed visitor has no session yet).
1920
function JoinPage() {
2021
const { code } = Route.useParams();
22+
const [inviteState, setInviteState] = useState<"checking" | "valid" | "invalid">("checking");
2123
const [name, setName] = useState("");
2224
const [email, setEmail] = useState("");
2325
const [password, setPassword] = useState("");
2426
const [error, setError] = useState<string | null>(null);
2527
const [busy, setBusy] = useState(false);
2628

29+
useEffect(() => {
30+
let alive = true;
31+
setInviteState("checking");
32+
void fetch(`/api/invite-status/${encodeURIComponent(code)}`, {
33+
credentials: "same-origin",
34+
}).then(
35+
async (response) => {
36+
const body = response.ok
37+
? ((await response.json().then(
38+
(value) => value,
39+
() => ({}),
40+
)) as { valid?: boolean })
41+
: {};
42+
if (alive) setInviteState(body.valid === true ? "valid" : "invalid");
43+
},
44+
() => {
45+
if (alive) setInviteState("invalid");
46+
},
47+
);
48+
return () => {
49+
alive = false;
50+
};
51+
}, [code]);
52+
2753
const submit = async (event: FormEvent) => {
2854
event.preventDefault();
2955
setBusy(true);
@@ -43,6 +69,30 @@ function JoinPage() {
4369
window.location.href = "/";
4470
};
4571

72+
if (inviteState === "checking") {
73+
return (
74+
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
75+
Loading…
76+
</div>
77+
);
78+
}
79+
80+
if (inviteState === "invalid") {
81+
return (
82+
<div className="flex min-h-screen items-center justify-center bg-background p-6">
83+
<Card className="w-full max-w-md">
84+
<CardHeader>
85+
<CardTitle>Invite not valid</CardTitle>
86+
<CardDescription>
87+
This invite link is invalid or has expired. Ask the person who invited you for a new
88+
link.
89+
</CardDescription>
90+
</CardHeader>
91+
</Card>
92+
</div>
93+
);
94+
}
95+
4696
return (
4797
<div className="flex min-h-screen items-center justify-center bg-background p-6">
4898
<form

0 commit comments

Comments
 (0)