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