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
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", · 14 unmodified lines
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 · 23 unmodified lines
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+10−0
@@ -20,6 +20,9 @@ export class SystemError extends Schema.TaggedErrorClass<SystemError>()( · 19 unmodified lines
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") · 7 unmodified lines
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+11−0
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"; · 4 unmodified lines
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 · 27 unmodified lines
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+51−1
@@ -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")({ · 10 unmodified lines
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() { · 13 unmodified lines
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