History

Validate join invites before showing signup (#1265)

Rhys Sullivan committed 4 months ago

Commit 2f8d7b61 parent b494be44 files · +84 −1Browse files

Unified · Split

4 files
  1. apps/host-selfhost/src/admin/invites.node.test.ts +12 −0
  2. apps/host-selfhost/src/system/api.ts +10 −0
  3. apps/host-selfhost/src/system/handlers.ts +11 −0
  4. apps/host-selfhost/web/routes/public/join.$code.tsx +51 −1
apps/host-selfhost/src/admin/invites.node.test.ts+12−0
@@ -27,6 +27,15 @@ const signUp = (body: Record<string, unknown>) => · 26 unmodified lines
27 }),27 }),
28 );28 );
29 29
30const 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
30test("open signup is closed: a signup without a valid invite code is rejected", async () => {39test("open signup is closed: a signup without a valid invite code is rejected", async () => {
31 const res = await signUp({40 const res = await signUp({
32 email: "intruder@invite.test",41 email: "intruder@invite.test",
@@ -47,6 +56,8 @@ test("open signup is closed: a signup without a valid invite code is rejected", · 14 unmodified lines
47test("a code minted via the admin API redeems into a real org membership", async () => {56test("a code minted via the admin API redeems into a real org membership", async () => {
48 // Minted through the TYPED admin HttpApi client (see mint-invite.ts).57 // Minted through the TYPED admin HttpApi client (see mint-invite.ts).
49 const inviteCode = await mintInviteCode(handler);58 const inviteCode = await mintInviteCode(handler);
59 expect(await inviteStatus("AAAA-BBBB-CCCC")).toBe(false);
60 expect(await inviteStatus(inviteCode)).toBe(true);
50 61
51 const res = await signUp({62 const res = await signUp({
52 email: "member@invite.test",63 email: "member@invite.test",
@@ -76,4 +87,5 @@ test("a code minted via the admin API redeems into a real org membership", async · 23 unmodified lines
76 inviteCode,87 inviteCode,
77 });88 });
78 expect(reuse.status).not.toBe(200);89 expect(reuse.status).not.toBe(200);
90 expect(await inviteStatus(inviteCode)).toBe(false);
79});91});
apps/host-selfhost/src/system/api.ts+10−0
@@ -20,6 +20,9 @@ export class SystemError extends Schema.TaggedErrorClass<SystemError>()( · 19 unmodified lines
20 20
21export const HealthResponse = Schema.Struct({ status: Schema.String });21export const HealthResponse = Schema.Struct({ status: Schema.String });
22export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean });22export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean });
23export const InviteStatusResponse = Schema.Struct({ valid: Schema.Boolean });
24
25const InviteStatusParams = { code: Schema.String };
23 26
24export const SystemApi = HttpApiGroup.make("system")27export const SystemApi = HttpApiGroup.make("system")
25 .add(28 .add(
@@ -33,6 +36,13 @@ export const SystemApi = HttpApiGroup.make("system") · 7 unmodified lines
33 success: SetupStatusResponse,36 success: SetupStatusResponse,
34 error: [SystemError],37 error: [SystemError],
35 }),38 }),
39 )
40 .add(
41 HttpApiEndpoint.get("inviteStatus", "/invite-status/:code", {
42 params: InviteStatusParams,
43 success: InviteStatusResponse,
44 error: [SystemError],
45 }),
36 );46 );
37 47
38export const SystemHttpApi = HttpApi.make("executor-self-host-system").add(SystemApi);48export const SystemHttpApi = HttpApi.make("executor-self-host-system").add(SystemApi);
apps/host-selfhost/src/system/handlers.ts+11−0
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"; · 4 unmodified lines
5import { SystemError, SystemHttpApi } from "./api";5import { SystemError, SystemHttpApi } from "./api";
6import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth";6import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth";
7import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db";7import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db";
8import { findRedeemableCode } from "../auth/invites";
8 9
9// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
10// Handlers for the public system API. Unauthenticated; every DB touch is an11// Handlers for the public system API. Unauthenticated; every DB touch is an
@@ -38,6 +39,16 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han · 27 unmodified lines
38 });39 });
39 return { needsSetup: count === 0 };40 return { needsSetup: count === 0 };
40 }),41 }),
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 }),
41 ),52 ),
42);53);
43 54
apps/host-selfhost/web/routes/public/join.$code.tsx+51−1
@@ -1,7 +1,8 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useState, type FormEvent } from "react";2import { useEffect, useState, type FormEvent } from "react";
3 3
4import { Button } from "@executor-js/react/components/button";4import { Button } from "@executor-js/react/components/button";
5import { Card, CardDescription, CardHeader, CardTitle } from "@executor-js/react/components/card";
5import { Input } from "@executor-js/react/components/input";6import { Input } from "@executor-js/react/components/input";
6import { Label } from "@executor-js/react/components/label";7import { Label } from "@executor-js/react/components/label";
7 8
@@ -18,12 +19,37 @@ export const Route = createFileRoute("/join/$code")({ · 10 unmodified lines
18// auth gate (an un-redeemed visitor has no session yet).19// auth gate (an un-redeemed visitor has no session yet).
19function JoinPage() {20function JoinPage() {
20 const { code } = Route.useParams();21 const { code } = Route.useParams();
22 const [inviteState, setInviteState] = useState<"checking" | "valid" | "invalid">("checking");
21 const [name, setName] = useState("");23 const [name, setName] = useState("");
22 const [email, setEmail] = useState("");24 const [email, setEmail] = useState("");
23 const [password, setPassword] = useState("");25 const [password, setPassword] = useState("");
24 const [error, setError] = useState<string | null>(null);26 const [error, setError] = useState<string | null>(null);
25 const [busy, setBusy] = useState(false);27 const [busy, setBusy] = useState(false);
26 28
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
27 const submit = async (event: FormEvent) => {53 const submit = async (event: FormEvent) => {
28 event.preventDefault();54 event.preventDefault();
29 setBusy(true);55 setBusy(true);
@@ -43,6 +69,30 @@ function JoinPage() { · 13 unmodified lines
43 window.location.href = "/";69 window.location.href = "/";
44 };70 };
45 71
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
46 return (96 return (
47 <div className="flex min-h-screen items-center justify-center bg-background p-6">97 <div className="flex min-h-screen items-center justify-center bg-background p-6">
48 <form98 <form