Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/host-selfhost/src/admin/invites.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ const signUp = (body: Record<string, unknown>) =>
}),
);

const inviteStatus = async (code: string): Promise<boolean> => {
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",
Expand All @@ -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",
Expand Down Expand Up @@ -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);
});
10 changes: 10 additions & 0 deletions apps/host-selfhost/src/system/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export class SystemError extends Schema.TaggedErrorClass<SystemError>()(

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(
Expand All @@ -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);
11 changes: 11 additions & 0 deletions apps/host-selfhost/src/system/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}),
),
);

Expand Down
52 changes: 51 additions & 1 deletion apps/host-selfhost/web/routes/public/join.$code.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<string | null>(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) => {
Comment on lines +44 to 53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Network / server errors silently shown as "Invite not valid"

Both a rejected fetch (network down, DNS failure) and a non-2xx response (DB error returning a SystemError) collapse into setInviteState("invalid"), which renders the "Invite not valid" card. A user holding a perfectly good invite code who visits during a brief server hiccup will be told their link is expired or invalid and given no way to retry. The fetch-rejection branch and the !response.ok body-override both need to distinguish between "the server said invalid" and "we couldn't reach the server at all" — a separate "error" state with a retry prompt would prevent the misleading dead-end.

event.preventDefault();
setBusy(true);
Expand All @@ -43,6 +69,30 @@ function JoinPage() {
window.location.href = "/";
};

if (inviteState === "checking") {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Loading…
</div>
);
}

if (inviteState === "invalid") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Invite not valid</CardTitle>
<CardDescription>
This invite link is invalid or has expired. Ask the person who invited you for a new
link.
</CardDescription>
</CardHeader>
</Card>
</div>
);
}

return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<form
Expand Down
Loading