diff --git a/apps/host-selfhost/src/admin/invites.node.test.ts b/apps/host-selfhost/src/admin/invites.node.test.ts index 697103246..b1d94bbf6 100644 --- a/apps/host-selfhost/src/admin/invites.node.test.ts +++ b/apps/host-selfhost/src/admin/invites.node.test.ts @@ -27,6 +27,15 @@ const signUp = (body: Record) => }), ); +const inviteStatus = async (code: string): Promise => { + const response = await handler( + new Request(`${BASE}/api/invite-status/${encodeURIComponent(code)}`), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { valid?: boolean }; + return body.valid === true; +}; + test("open signup is closed: a signup without a valid invite code is rejected", async () => { const res = await signUp({ email: "intruder@invite.test", @@ -47,6 +56,8 @@ test("open signup is closed: a signup without a valid invite code is rejected", test("a code minted via the admin API redeems into a real org membership", async () => { // Minted through the TYPED admin HttpApi client (see mint-invite.ts). const inviteCode = await mintInviteCode(handler); + expect(await inviteStatus("AAAA-BBBB-CCCC")).toBe(false); + expect(await inviteStatus(inviteCode)).toBe(true); const res = await signUp({ email: "member@invite.test", @@ -76,4 +87,5 @@ test("a code minted via the admin API redeems into a real org membership", async inviteCode, }); expect(reuse.status).not.toBe(200); + expect(await inviteStatus(inviteCode)).toBe(false); }); diff --git a/apps/host-selfhost/src/system/api.ts b/apps/host-selfhost/src/system/api.ts index ccc9d3568..4b6200dc8 100644 --- a/apps/host-selfhost/src/system/api.ts +++ b/apps/host-selfhost/src/system/api.ts @@ -20,6 +20,9 @@ export class SystemError extends Schema.TaggedErrorClass()( export const HealthResponse = Schema.Struct({ status: Schema.String }); export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean }); +export const InviteStatusResponse = Schema.Struct({ valid: Schema.Boolean }); + +const InviteStatusParams = { code: Schema.String }; export const SystemApi = HttpApiGroup.make("system") .add( @@ -33,6 +36,13 @@ export const SystemApi = HttpApiGroup.make("system") success: SetupStatusResponse, error: [SystemError], }), + ) + .add( + HttpApiEndpoint.get("inviteStatus", "/invite-status/:code", { + params: InviteStatusParams, + success: InviteStatusResponse, + error: [SystemError], + }), ); export const SystemHttpApi = HttpApi.make("executor-self-host-system").add(SystemApi); diff --git a/apps/host-selfhost/src/system/handlers.ts b/apps/host-selfhost/src/system/handlers.ts index 6a0f9a381..8ecb4199b 100644 --- a/apps/host-selfhost/src/system/handlers.ts +++ b/apps/host-selfhost/src/system/handlers.ts @@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"; import { SystemError, SystemHttpApi } from "./api"; import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth"; import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; +import { findRedeemableCode } from "../auth/invites"; // --------------------------------------------------------------------------- // Handlers for the public system API. Unauthenticated; every DB touch is an @@ -38,6 +39,16 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han }); return { needsSetup: count === 0 }; }), + ) + .handle("inviteStatus", ({ params }) => + Effect.gen(function* () { + const { client } = yield* SelfHostDb; + const code = yield* Effect.tryPromise({ + try: () => findRedeemableCode(client, params.code), + catch: () => new SystemError({ message: "failed to read invite status" }), + }); + return { valid: code !== null }; + }), ), ); diff --git a/apps/host-selfhost/web/routes/public/join.$code.tsx b/apps/host-selfhost/web/routes/public/join.$code.tsx index 26b757f94..b95aa94a4 100644 --- a/apps/host-selfhost/web/routes/public/join.$code.tsx +++ b/apps/host-selfhost/web/routes/public/join.$code.tsx @@ -1,7 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { Button } from "@executor-js/react/components/button"; +import { Card, CardDescription, CardHeader, CardTitle } from "@executor-js/react/components/card"; import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; @@ -18,12 +19,37 @@ export const Route = createFileRoute("/join/$code")({ // auth gate (an un-redeemed visitor has no session yet). function JoinPage() { const { code } = Route.useParams(); + const [inviteState, setInviteState] = useState<"checking" | "valid" | "invalid">("checking"); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + useEffect(() => { + let alive = true; + setInviteState("checking"); + void fetch(`/api/invite-status/${encodeURIComponent(code)}`, { + credentials: "same-origin", + }).then( + async (response) => { + const body = response.ok + ? ((await response.json().then( + (value) => value, + () => ({}), + )) as { valid?: boolean }) + : {}; + if (alive) setInviteState(body.valid === true ? "valid" : "invalid"); + }, + () => { + if (alive) setInviteState("invalid"); + }, + ); + return () => { + alive = false; + }; + }, [code]); + const submit = async (event: FormEvent) => { event.preventDefault(); setBusy(true); @@ -43,6 +69,30 @@ function JoinPage() { window.location.href = "/"; }; + if (inviteState === "checking") { + return ( +
+ Loading… +
+ ); + } + + if (inviteState === "invalid") { + return ( +
+ + + Invite not valid + + This invite link is invalid or has expired. Ask the person who invited you for a new + link. + + + +
+ ); + } + return (