History

Handle invited users on first sign-in

Invited users who hadn't accepted their invitation yet were 500ing on the auth callback: the handler picked the first membership returned by WorkOS (which can be `pending` for unaccepted invites) and tried to refresh the session into it, which 400s. The fix is twofold: - Callback now skips non-active memberships when rehydrating an org-less session, and treats refresh failures as best-effort rather than fatal. - Onboarding surfaces pending invitations so invited users can explicitly accept them (with inviter attribution as a phishing signal) instead of silently landing on a create-org form and spawning a duplicate org. Also relaxes the callback schema to allow stateless redirects (some WorkOS-initiated flows arrive without a `state` query param). When state is present, the CSRF check is still enforced.

Rhys Sullivan committed 6 months ago

Commit f237cd71 parent 81e82e46 files · +579 −49Browse files

Unified · Split

6 files
  1. apps/cloud/src/auth/api.ts +43 −1
  2. apps/cloud/src/auth/handlers.node.test.ts +117 −0
  3. apps/cloud/src/auth/handlers.ts +126 −16
  4. apps/cloud/src/auth/workos.ts +8 −0
  5. apps/cloud/src/web/auth.tsx +7 −0
  6. apps/cloud/src/web/pages/onboarding.tsx +278 −32
apps/cloud/src/auth/api.ts+43−1
@@ -43,9 +43,38 @@ const CreateOrganizationResponse = Schema.Struct({ · 42 unmodified lines
4343 name: Schema.String,
4444 });
4545
46+// `state` is optional — some WorkOS-initiated redirects arrive at the
47+// callback without the state we set on /auth/login. The CSRF check is
48+// only enforced when state is present (see callback handler).
4649 const AuthCallbackSearch = Schema.Struct({
4750 code: Schema.String,
48− state: Schema.String,
51+ state: Schema.optional(Schema.String),
52+});
53+
54+const PendingInvitationInviter = Schema.Struct({
55+ email: Schema.String,
56+ name: Schema.NullOr(Schema.String),
57+});
58+
59+const PendingInvitation = Schema.Struct({
60+ id: Schema.String,
61+ organizationId: Schema.String,
62+ organizationName: Schema.String,
63+ createdAt: Schema.String,
64+ inviter: Schema.NullOr(PendingInvitationInviter),
65+});
66+
67+const PendingInvitationsResponse = Schema.Struct({
68+ invitations: Schema.Array(PendingInvitation),
69+});
70+
71+const AcceptInvitationBody = Schema.Struct({
72+ invitationId: Schema.String,
73+});
74+
75+const AcceptInvitationResponse = Schema.Struct({
76+ id: Schema.String,
77+ name: Schema.String,
4978 });
5079
5180 export const AUTH_PATHS = {
@@ -95,4 +124,17 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 43 unmodified lines
95124 error: AuthErrors,
96125 }),
97126 )
127+ .add(
128+ HttpApiEndpoint.get("pendingInvitations", "/auth/pending-invitations", {
129+ success: PendingInvitationsResponse,
130+ error: WorkOSError,
131+ }),
132+ )
133+ .add(
134+ HttpApiEndpoint.post("acceptInvitation", "/auth/accept-invitation", {
135+ payload: AcceptInvitationBody,
136+ success: AcceptInvitationResponse,
137+ error: AuthErrors,
138+ }),
139+ )
98140 .middleware(SessionAuth) {}
apps/cloud/src/auth/handlers.node.test.ts+117−0
@@ -7,6 +7,7 @@ import type { Effect as EffectType } from "effect/Effect"; · 6 unmodified lines
77 import { CloudAuthPublicApi } from "./api";
88 import { CloudAuthPublicHandlers } from "./handlers";
99 import { UserStoreService } from "./context";
10+import { WorkOSError } from "./errors";
1011 import { WorkOSAuth } from "./workos";
1112
1213 const TestAuthPublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi);
@@ -108,6 +109,33 @@ describe("Auth callback handlers", () => { · 95 unmodified lines
108109 }),
109110 );
110111
112+ // Some WorkOS-initiated redirects don't include a state parameter.
113+ // The schema treats state as optional; the handler skips the CSRF
114+ // check when state is absent and proceeds with the code exchange.
115+ it.effect("accepts a callback without state", () =>
116+ Effect.gen(function* () {
117+ const fetch = makeAuthFetch({
118+ authenticateWithCode: () =>
119+ Effect.succeed({
120+ user: fakeUser,
121+ accessToken: "access_token",
122+ refreshToken: "refresh_token",
123+ organizationId: "org_1",
124+ sealedSession: "sealed_session_no_state",
125+ } satisfies AuthenticateWithCodeResult),
126+ });
127+
128+ const response = yield* Effect.promise(() =>
129+ fetch(new Request("http://test.local/auth/callback?code=stateless-code")),
130+ );
131+
132+ expect(response.status).toBe(302);
133+ expect(response.headers.get("location")).toBe("/");
134+ const setCookie = response.headers.get("set-cookie") ?? "";
135+ expect(setCookie).toContain("wos-session=sealed_session_no_state");
136+ }),
137+ );
138+
111139 it.effect("sets the session cookie and clears login state on matching callback state", () =>
112140 Effect.gen(function* () {
113141 const fetch = makeAuthFetch({
@@ -138,4 +166,93 @@ describe("Auth callback handlers", () => { · 24 unmodified lines
138166 expect(setCookie).toContain("Max-Age=0");
139167 }),
140168 );
169+
170+ // Regression: an invited user signing in for the first time has only a
171+ // *pending* membership (the WorkOS-side representation of an unaccepted
172+ // invitation). The callback used to pick `data[0]` regardless of status
173+ // and call refreshSession into that org, which 400s and surfaces as
174+ // WorkOSError → 500. We now skip pending memberships entirely.
175+ it.effect("does not refresh into a pending membership; leaves session org-less", () =>
176+ Effect.gen(function* () {
177+ let refreshCalls = 0;
178+ const fetch = makeAuthFetch({
179+ authenticateWithCode: () =>
180+ Effect.succeed({
181+ user: fakeUser,
182+ accessToken: "access_token",
183+ refreshToken: "refresh_token",
184+ sealedSession: "sealed_session_no_org",
185+ } satisfies AuthenticateWithCodeResult),
186+ listUserMemberships: (() =>
187+ Effect.succeed({
188+ data: [
189+ {
190+ organizationId: "org_pending",
191+ status: "pending",
192+ },
193+ ],
194+ })) as unknown as WorkOSAuth["Service"]["listUserMemberships"],
195+ refreshSession: () =>
196+ Effect.sync(() => {
197+ refreshCalls++;
198+ return null;
199+ }),
200+ });
201+
202+ const response = yield* Effect.promise(() =>
203+ fetch(
204+ new Request("http://test.local/auth/callback?code=code&state=state_2", {
205+ headers: { cookie: "wos-login-state=state_2" },
206+ }),
207+ ),
208+ );
209+
210+ expect(response.status).toBe(302);
211+ expect(response.headers.get("location")).toBe("/");
212+ expect(refreshCalls).toBe(0);
213+ const setCookie = response.headers.get("set-cookie") ?? "";
214+ expect(setCookie).toContain("wos-session=sealed_session_no_org");
215+ }),
216+ );
217+
218+ // Regression: even with an active membership, if WorkOS rejects the
219+ // refresh (e.g. membership just revoked, brief race), the callback
220+ // should still succeed with an org-less session rather than 500.
221+ it.effect("falls through to org-less session when refresh fails", () =>
222+ Effect.gen(function* () {
223+ const fetch = makeAuthFetch({
224+ authenticateWithCode: () =>
225+ Effect.succeed({
226+ user: fakeUser,
227+ accessToken: "access_token",
228+ refreshToken: "refresh_token",
229+ sealedSession: "sealed_session_fallback",
230+ } satisfies AuthenticateWithCodeResult),
231+ listUserMemberships: (() =>
232+ Effect.succeed({
233+ data: [
234+ {
235+ organizationId: "org_active",
236+ status: "active",
237+ },
238+ ],
239+ })) as unknown as WorkOSAuth["Service"]["listUserMemberships"],
240+ refreshSession: (() =>
241+ Effect.fail(new WorkOSError())) as WorkOSAuth["Service"]["refreshSession"],
242+ });
243+
244+ const response = yield* Effect.promise(() =>
245+ fetch(
246+ new Request("http://test.local/auth/callback?code=code&state=state_3", {
247+ headers: { cookie: "wos-login-state=state_3" },
248+ }),
249+ ),
250+ );
251+
252+ expect(response.status).toBe(302);
253+ expect(response.headers.get("location")).toBe("/");
254+ const setCookie = response.headers.get("set-cookie") ?? "";
255+ expect(setCookie).toContain("wos-session=sealed_session_fallback");
256+ }),
257+ );
141258 });
apps/cloud/src/auth/handlers.ts+126−16
@@ -110,11 +110,17 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 109 unmodified lines
110110 const workos = yield* WorkOSAuth;
111111 const users = yield* UserStoreService;
112112 const cookieState = request.cookies[STATE_COOKIE] ?? null;
113− if (!cookieState || !timingSafeEqual(cookieState, query.state)) {
114− return deleteResponseCookie(
115− HttpServerResponse.text("Invalid login state", { status: 400 }),
116− STATE_COOKIE,
117− );
113+ // CSRF check is only enforced when the redirect carries a state
114+ // value — some WorkOS-initiated redirects don't include one.
115+ // When state is present, it MUST match the cookie we set on
116+ // /login.
117+ if (query.state !== undefined) {
118+ if (!cookieState || !timingSafeEqual(cookieState, query.state)) {
119+ return deleteResponseCookie(
120+ HttpServerResponse.text("Invalid login state", { status: 400 }),
121+ STATE_COOKIE,
122+ );
123+ }
118124 }
119125
120126 const result = yield* workos.authenticateWithCode(query.code);
@@ -124,19 +130,24 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 3 unmodified lines
124130
125131 let sealedSession = result.sealedSession;
126132
127− // If the auth response didn't surface an org but the user already
128− // belongs to one, rehydrate the session with it. If they have no
129− // memberships at all, leave the session org-less — the frontend
130− // AuthGate will render the onboarding flow. We never auto-create
131− // organizations on login.
133+ // If the auth response didn't surface an org but the user is already
134+ // an *active* member of one, rehydrate the session with it. Pending
135+ // memberships (which represent unaccepted invitations on WorkOS's
136+ // side) are skipped — refreshing into one 400s, and silently
137+ // attaching an unaccepted org would also bypass invite consent.
138+ // If they have no active memberships, leave the session org-less —
139+ // AuthGate's onboarding flow surfaces pending invites and the
140+ // create-org form. We never auto-create organizations on login.
132141 if (!result.organizationId && sealedSession) {
133142 const memberships = yield* workos.listUserMemberships(result.user.id);
134− const existing = memberships.data[0];
135− if (existing) {
136− const refreshed = yield* workos.refreshSession(
137− sealedSession,
138− existing.organizationId,
139− );
143+ const existingActive = memberships.data.find((m) => m.status === "active");
144+ if (existingActive) {
145+ // Best-effort refresh — if WorkOS rejects (e.g. the membership
146+ // was just revoked), fall through to an org-less session rather
147+ // than 500ing the entire callback.
148+ const refreshed = yield* workos
149+ .refreshSession(sealedSession, existingActive.organizationId)
150+ .pipe(Effect.orElseSucceed(() => null));
140151 if (refreshed) sealedSession = refreshed;
141152 }
142153 }
@@ -262,6 +273,105 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 119 unmodified lines
262273 return yield* Effect.fail(new WorkOSError());
263274 }
264275
276+ setCookie("wos-session", refreshed, COOKIE_OPTIONS);
277+ return { id: org.id, name: org.name };
278+ }),
279+ )
280+ .handle("pendingInvitations", () =>
281+ Effect.gen(function* () {
282+ const workos = yield* WorkOSAuth;
283+ const session = yield* SessionContext;
284+
285+ const invitations = yield* workos.listInvitationsByEmail(session.email);
286+ const pending = invitations.data.filter(
287+ (i) => i.state === "pending" && i.organizationId !== null,
288+ );
289+
290+ // Resolve org names + inviter identities in parallel. Treat
291+ // individual failures as "skip the field" rather than failing the
292+ // whole list — a stale invitation pointing at a deleted org
293+ // shouldn't block the user from seeing the others, and a missing
294+ // inviter is normal (admin-/API-created invitations have no
295+ // inviter user).
296+ const enriched = yield* Effect.all(
297+ pending.map((inv) =>
298+ Effect.gen(function* () {
299+ const org = yield* workos
300+ .getOrganization(inv.organizationId!)
301+ .pipe(Effect.orElseSucceed(() => null));
302+ if (!org) return null;
303+ const inviter = inv.inviterUserId
304+ ? yield* workos.getUser(inv.inviterUserId).pipe(
305+ Effect.map((u) => ({
306+ email: u.email,
307+ name:
308+ [u.firstName, u.lastName].filter(Boolean).join(" ") || null,
309+ })),
310+ Effect.orElseSucceed(() => null),
311+ )
312+ : null;
313+ return {
314+ id: inv.id,
315+ organizationId: org.id,
316+ organizationName: org.name,
317+ createdAt: inv.createdAt,
318+ inviter,
319+ };
320+ }),
321+ ),
322+ { concurrency: "unbounded" },
323+ );
324+
325+ return {
326+ invitations: enriched.filter((i): i is NonNullable<typeof i> => i !== null),
327+ };
328+ }),
329+ )
330+ .handle("acceptInvitation", ({ payload }) =>
331+ Effect.gen(function* () {
332+ const workos = yield* WorkOSAuth;
333+ const users = yield* UserStoreService;
334+ const session = yield* SessionContext;
335+
336+ const invitation = yield* workos.acceptInvitation(payload.invitationId);
337+
338+ // Defensive: invitations created without an org shouldn't reach
339+ // this UI, but the SDK type allows null so guard anyway.
340+ if (!invitation.organizationId) {
341+ yield* Effect.logWarning("acceptInvitation: invitation has no organizationId", {
342+ invitationId: payload.invitationId,
343+ });
344+ return yield* Effect.fail(new WorkOSError());
345+ }
346+
347+ // Mirror the org locally so domain tables can FK against it.
348+ const org = yield* workos.getOrganization(invitation.organizationId);
349+ yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
350+
351+ // Attach the just-accepted org to the current session. Same shape
352+ // as createOrganization: refresh + verify; if we can't pin the
353+ // session in-place, clear the cookie and let the user bounce
354+ // through login again. The acceptance has already succeeded
355+ // server-side, so the next login will pick up the membership.
356+ const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
357+ const verified = refreshed
358+ ? yield* workos.authenticateSealedSession(refreshed)
359+ : null;
360+
361+ if (!refreshed || !verified || verified.organizationId !== org.id) {
362+ yield* Effect.logWarning(
363+ "acceptInvitation: unable to attach org to current session",
364+ {
365+ userId: session.accountId,
366+ orgId: org.id,
367+ refreshReturnedSession: refreshed != null,
368+ verifiedOrgId: verified?.organizationId ?? null,
369+ },
370+ );
371+ deleteCookie("wos-session", { path: "/" });
372+ return yield* Effect.fail(new WorkOSError());
373+ }
374+
265375 setCookie("wos-session", refreshed, COOKIE_OPTIONS);
266376 return { id: org.id, name: org.name };
267377 }),
apps/cloud/src/auth/workos.ts+8−0
@@ -185,6 +185,14 @@ const make = Effect.gen(function* () { · 184 unmodified lines
185185 })),
186186 ),
187187
188+ /** List invitations for an email address (across all orgs). */
189+ listInvitationsByEmail: (email: string) =>
190+ use((wos) => wos.userManagement.listInvitations({ email })),
191+
192+ /** Accept an invitation; returns the (now accepted) invitation. */
193+ acceptInvitation: (invitationId: string) =>
194+ use((wos) => wos.userManagement.acceptInvitation(invitationId)),
195+
188196 /** Remove an organization membership. */
189197 deleteOrgMembership: (membershipId: string) =>
190198 use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)),
apps/cloud/src/web/auth.tsx+7−0
@@ -42,6 +42,13 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 41 unmodified lines
4242 export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
4343 export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
4444
45+export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
46+ timeToLive: "1 minute",
47+ reactivityKeys: [ReactivityKey.auth],
48+});
49+
50+export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvitation");
51+
4552 // ---------------------------------------------------------------------------
4653 // Provider + hook
4754 // ---------------------------------------------------------------------------
apps/cloud/src/web/pages/onboarding.tsx+278−32
@@ -1,14 +1,72 @@
1+import { useState } from "react";
2+import { useAtomValue, useAtomSet } from "@effect/atom-react";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+import * as Exit from "effect/Exit";
5+import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
16 import { Button } from "@executor-js/react/components/button";
7+import { Skeleton } from "@executor-js/react/components/skeleton";
28
39 import { AUTH_PATHS } from "../../auth/api";
4−import { useAuth } from "../auth";
10+import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth";
511 import {
612 CreateOrganizationFields,
713 useCreateOrganizationForm,
814 } from "../components/create-organization-form";
915
16+type PendingInvitation = {
17+ id: string;
18+ organizationId: string;
19+ organizationName: string;
20+ createdAt: string;
21+ inviter: { email: string; name: string | null } | null;
22+};
23+
24+const formatRelativeTime = (iso: string): string => {
25+ const then = new Date(iso).getTime();
26+ if (Number.isNaN(then)) return "";
27+ const diffMs = Date.now() - then;
28+ const min = Math.floor(diffMs / 60_000);
29+ if (min < 1) return "just now";
30+ if (min < 60) return `${min}m ago`;
31+ const hrs = Math.floor(min / 60);
32+ if (hrs < 24) return `${hrs}h ago`;
33+ const days = Math.floor(hrs / 24);
34+ if (days < 7) return `${days}d ago`;
35+ const weeks = Math.floor(days / 7);
36+ if (weeks < 5) return `${weeks}w ago`;
37+ const months = Math.floor(days / 30);
38+ if (months < 12) return `${months}mo ago`;
39+ return `${Math.floor(days / 365)}y ago`;
40+};
41+
1042 export const OnboardingPage = () => {
1143 const auth = useAuth();
44+ const invitationsResult = useAtomValue(pendingInvitationsAtom);
45+ const doAccept = useAtomSet(acceptInvitation, { mode: "promiseExit" });
46+
47+ const [acceptingId, setAcceptingId] = useState<string | null>(null);
48+ const [errorByInvitationId, setErrorByInvitationId] = useState<Record<string, string>>({});
49+
50+ const handleAccept = async (invitation: PendingInvitation) => {
51+ setAcceptingId(invitation.id);
52+ setErrorByInvitationId((prev) => {
53+ if (!(invitation.id in prev)) return prev;
54+ const next = { ...prev };
55+ delete next[invitation.id];
56+ return next;
57+ });
58+ const exit = await doAccept({
59+ payload: { invitationId: invitation.id },
60+ reactivityKeys: authWriteKeys,
61+ });
62+ setAcceptingId(null);
63+ if (!Exit.isSuccess(exit)) {
64+ setErrorByInvitationId((prev) => ({
65+ ...prev,
66+ [invitation.id]: "Couldn't accept this invitation. Try again or ask the inviter to resend.",
67+ }));
68+ }
69+ };
1270
1371 const suggestedName =
1472 auth.status === "authenticated" &&
@@ -19,38 +77,70 @@ export const OnboardingPage = () => { · 4 unmodified lines
1977
2078 const form = useCreateOrganizationForm({
2179 defaultName: suggestedName,
22− // The server sets a cookie for the new (or cleared) org on both success and
23− // failure. The authWriteKeys passed at the mutation site auto-invalidate
24− // authAtom so AuthGate re-routes into Shell or LoginPage as appropriate.
2580 onSuccess: () => {},
2681 });
2782
83+ const isLoading = AsyncResult.isInitial(invitationsResult) || AsyncResult.isWaiting(invitationsResult);
84+ const invitations = AsyncResult.match(invitationsResult, {
85+ onInitial: () => [] as readonly PendingInvitation[],
86+ onFailure: () => [] as readonly PendingInvitation[],
87+ onSuccess: ({ value }) => value.invitations,
88+ });
89+
90+ const count = invitations.length;
91+ const sole = count === 1 ? invitations[0]! : null;
92+
2893 return (
2994 <div className="flex min-h-screen items-center justify-center bg-background px-4">
3095 <div className="mx-auto flex w-full max-w-sm flex-col gap-6">
31− <div className="flex flex-col gap-2">
32− <h1 className="font-serif text-3xl">Create your organization</h1>
33− <p className="text-sm text-muted-foreground">
34− Organizations group your sources, secrets, and teammates. You can invite others once
35− it's set up.
36− </p>
37− </div>
38−
39− <CreateOrganizationFields
40− name={form.name}
41− onNameChange={(name) => {
42− form.setName(name);
43− if (form.error) form.setError(null);
44− }}
45− error={form.error}
46− onSubmit={() => void form.submit()}
47− />
48−
49− <div className="flex items-center justify-between gap-3">
96+ <header className="flex flex-col gap-2">
97+ <h1 className="font-serif text-3xl">
98+ {isLoading
99+ ? "Loading"
100+ : count === 0
101+ ? "Create your organization"
102+ : "You've been invited"}
103+ </h1>
104+ {!isLoading && count === 0 && (
105+ <p className="text-sm text-muted-foreground">
106+ Organizations group your sources, secrets, and teammates. You can invite others once
107+ it's set up.
108+ </p>
109+ )}
110+ </header>
111+
112+ {isLoading && <InvitationsSkeleton />}
113+
114+ {!isLoading && sole && (
115+ <SingleInvitationView
116+ invitation={sole}
117+ accepting={acceptingId === sole.id}
118+ error={errorByInvitationId[sole.id] ?? null}
119+ onAccept={() => void handleAccept(sole)}
120+ />
121+ )}
122+
123+ {!isLoading && count > 1 && (
124+ <MultiInvitationsView
125+ invitations={invitations}
126+ acceptingId={acceptingId}
127+ errorByInvitationId={errorByInvitationId}
128+ onAccept={(inv) => void handleAccept(inv)}
129+ />
130+ )}
131+
132+ {!isLoading && (count === 0 || sole || count > 1) && (
133+ <CreateOrgSection
134+ isPrimary={count === 0}
135+ form={form}
136+ />
137+ )}
138+
139+ <footer className="flex items-center justify-center">
50140 {/* oxlint-disable-next-line react/forbid-elements */}
51141 <button
52142 type="button"
53− className="text-xs text-muted-foreground hover:text-foreground"
143+ className="text-xs text-muted-foreground transition-colors hover:text-foreground"
54144 onClick={async () => {
55145 await fetch(AUTH_PATHS.logout, { method: "POST" });
56146 window.location.href = "/";
@@ -58,15 +148,171 @@ export const OnboardingPage = () => { · 1 unmodified line
58148 >
59149 Sign out
60150 </button>
61− <Button
62− size="sm"
63− onClick={() => void form.submit()}
64− disabled={!form.canSubmit || form.creating}
65− >
66− {form.creating ? "Creating…" : "Create organization"}
67− </Button>
68− </div>
151+ </footer>
69152 </div>
70153 </div>
71154 );
72155 };
156+
157+// ---------------------------------------------------------------------------
158+
159+const InvitationsSkeleton = () => (
160+ <div className="flex flex-col gap-2.5">
161+ <InvitationRowSkeleton />
162+ <InvitationRowSkeleton />
163+ </div>
164+);
165+
166+const InvitationRowSkeleton = () => (
167+ <div className="flex items-center gap-3 rounded-md border border-border px-3 py-2.5">
168+ <div className="flex flex-1 flex-col gap-1.5">
169+ <Skeleton className="h-3.5 w-2/5" />
170+ <Skeleton className="h-3 w-3/5" />
171+ </div>
172+ <Skeleton className="h-8 w-16 rounded-md" />
173+ </div>
174+);
175+
176+// ---------------------------------------------------------------------------
177+
178+const InviterAttribution = ({ invitation }: { invitation: PendingInvitation }) => {
179+ const inviterLabel = invitation.inviter
180+ ? invitation.inviter.name && invitation.inviter.name.length > 0
181+ ? `${invitation.inviter.name} (${invitation.inviter.email})`
182+ : invitation.inviter.email
183+ : null;
184+ const time = formatRelativeTime(invitation.createdAt);
185+ return (
186+ <p className="truncate text-xs text-muted-foreground">
187+ {inviterLabel ? (
188+ <>
189+ Invited by <span className="text-foreground/80">{inviterLabel}</span>
190+ {time && <span> · {time}</span>}
191+ </>
192+ ) : (
193+ <>Invited{time ? ` ${time}` : ""}</>
194+ )}
195+ </p>
196+ );
197+};
198+
199+// ---------------------------------------------------------------------------
200+
201+const SingleInvitationView = ({
202+ invitation,
203+ accepting,
204+ error,
205+ onAccept,
206+}: {
207+ invitation: PendingInvitation;
208+ accepting: boolean;
209+ error: string | null;
210+ onAccept: () => void;
211+}) => (
212+ <section aria-label="Invitation" className="flex flex-col gap-3">
213+ <div className="flex flex-col gap-0.5 rounded-md border border-border px-3 py-2.5">
214+ <p className="truncate text-sm font-medium leading-tight">{invitation.organizationName}</p>
215+ <InviterAttribution invitation={invitation} />
216+ </div>
217+
218+ {error && <p className="text-xs text-destructive">{error}</p>}
219+
220+ <Button onClick={onAccept} disabled={accepting} className="w-full" size="sm">
221+ {accepting ? "Joining…" : "Accept invitation"}
222+ </Button>
223+ </section>
224+);
225+
226+// ---------------------------------------------------------------------------
227+
228+const MultiInvitationsView = ({
229+ invitations,
230+ acceptingId,
231+ errorByInvitationId,
232+ onAccept,
233+}: {
234+ invitations: readonly PendingInvitation[];
235+ acceptingId: string | null;
236+ errorByInvitationId: Record<string, string>;
237+ onAccept: (invitation: PendingInvitation) => void;
238+}) => (
239+ <section aria-label="Invitations" className="flex flex-col gap-2">
240+ <ul className="flex flex-col gap-2">
241+ {invitations.map((invitation) => {
242+ const isAccepting = acceptingId === invitation.id;
243+ const isOtherAccepting = acceptingId !== null && !isAccepting;
244+ const error = errorByInvitationId[invitation.id] ?? null;
245+ return (
246+ <li key={invitation.id} className="flex flex-col gap-1.5">
247+ <div className="flex items-center gap-3 rounded-md border border-border px-3 py-2.5">
248+ <div className="min-w-0 flex-1">
249+ <p className="truncate text-sm font-medium leading-tight">
250+ {invitation.organizationName}
251+ </p>
252+ <InviterAttribution invitation={invitation} />
253+ </div>
254+ <Button
255+ size="sm"
256+ onClick={() => onAccept(invitation)}
257+ disabled={isAccepting || isOtherAccepting}
258+ >
259+ {isAccepting ? "Joining…" : "Accept"}
260+ </Button>
261+ </div>
262+ {error && <p className="text-xs text-destructive">{error}</p>}
263+ </li>
264+ );
265+ })}
266+ </ul>
267+ </section>
268+);
269+
270+// ---------------------------------------------------------------------------
271+
272+const CreateOrgSection = ({
273+ isPrimary,
274+ form,
275+}: {
276+ isPrimary: boolean;
277+ form: ReturnType<typeof useCreateOrganizationForm>;
278+}) => {
279+ const [expanded, setExpanded] = useState(isPrimary);
280+
281+ if (!isPrimary && !expanded) {
282+ return (
283+ <div className="text-center text-xs text-muted-foreground">
284+ {/* oxlint-disable-next-line react/forbid-elements */}
285+ <button
286+ type="button"
287+ onClick={() => setExpanded(true)}
288+ className="underline-offset-4 transition-colors hover:text-foreground hover:underline"
289+ >
290+ Or create a new organization
291+ </button>
292+ </div>
293+ );
294+ }
295+
296+ return (
297+ <section aria-label="Create organization" className="flex flex-col gap-3">
298+ <CreateOrganizationFields
299+ name={form.name}
300+ onNameChange={(name) => {
301+ form.setName(name);
302+ if (form.error) form.setError(null);
303+ }}
304+ error={form.error}
305+ onSubmit={() => void form.submit()}
306+ />
307+ <div className="flex justify-end">
308+ <Button
309+ size="sm"
310+ onClick={() => void form.submit()}
311+ disabled={!form.canSubmit || form.creating}
312+ >
313+ {form.creating ? "Creating…" : "Create organization"}
314+ </Button>
315+ </div>
316+ </section>
317+ );
318+};

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t