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 | test("open signup is closed: a signup without a valid invite code is rejected", async () => { | 39 | test("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 | |||
| 47 | test("a code minted via the admin API redeems into a real org membership", async () => { | 56 | test("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 | }); |
| @@ -20,6 +20,9 @@ export class SystemError extends Schema.TaggedErrorClass<SystemError>()( · 19 unmodified lines | |||
| 20 | 20 | ||
| 21 | export const HealthResponse = Schema.Struct({ status: Schema.String }); | 21 | export const HealthResponse = Schema.Struct({ status: Schema.String }); |
| 22 | export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean }); | 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 | export const SystemApi = HttpApiGroup.make("system") | 27 | export 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 | ||
| 38 | export const SystemHttpApi = HttpApi.make("executor-self-host-system").add(SystemApi); | 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 | import { SystemError, SystemHttpApi } from "./api"; | 5 | import { SystemError, SystemHttpApi } from "./api"; |
| 6 | import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth"; | 6 | import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth"; |
| 7 | import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; | 7 | import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; |
| 8 | import { findRedeemableCode } from "../auth/invites"; | ||
| 8 | 9 | ||
| 9 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 10 | // Handlers for the public system API. Unauthenticated; every DB touch is an | 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 | 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 | ||
| @@ -1,7 +1,8 @@ | |||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 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 | import { Button } from "@executor-js/react/components/button"; | 4 | import { Button } from "@executor-js/react/components/button"; |
| 5 | import { Card, CardDescription, CardHeader, CardTitle } from "@executor-js/react/components/card"; | ||
| 5 | import { Input } from "@executor-js/react/components/input"; | 6 | import { Input } from "@executor-js/react/components/input"; |
| 6 | import { Label } from "@executor-js/react/components/label"; | 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 | // auth gate (an un-redeemed visitor has no session yet). | 19 | // auth gate (an un-redeemed visitor has no session yet). |
| 19 | function JoinPage() { | 20 | function 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 | <form | 98 | <form |