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
43 name: Schema.String,43 name: Schema.String,
44});44});
45 45
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).
46const AuthCallbackSearch = Schema.Struct({49const AuthCallbackSearch = Schema.Struct({
47 code: Schema.String,50 code: Schema.String,
48 state: Schema.String,51 state: Schema.optional(Schema.String),
52});
53
54const PendingInvitationInviter = Schema.Struct({
55 email: Schema.String,
56 name: Schema.NullOr(Schema.String),
57});
58
59const 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
67const PendingInvitationsResponse = Schema.Struct({
68 invitations: Schema.Array(PendingInvitation),
69});
70
71const AcceptInvitationBody = Schema.Struct({
72 invitationId: Schema.String,
73});
74
75const AcceptInvitationResponse = Schema.Struct({
76 id: Schema.String,
77 name: Schema.String,
49});78});
50 79
51export const AUTH_PATHS = {80export const AUTH_PATHS = {
@@ -95,4 +124,17 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 43 unmodified lines
95 error: AuthErrors,124 error: AuthErrors,
96 }),125 }),
97 )126 )
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 )
98 .middleware(SessionAuth) {}140 .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
7import { CloudAuthPublicApi } from "./api";7import { CloudAuthPublicApi } from "./api";
8import { CloudAuthPublicHandlers } from "./handlers";8import { CloudAuthPublicHandlers } from "./handlers";
9import { UserStoreService } from "./context";9import { UserStoreService } from "./context";
10import { WorkOSError } from "./errors";
10import { WorkOSAuth } from "./workos";11import { WorkOSAuth } from "./workos";
11 12
12const TestAuthPublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi);13const TestAuthPublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi);
@@ -108,6 +109,33 @@ describe("Auth callback handlers", () => { · 95 unmodified lines
108 }),109 }),
109 );110 );
110 111
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
111 it.effect("sets the session cookie and clears login state on matching callback state", () =>139 it.effect("sets the session cookie and clears login state on matching callback state", () =>
112 Effect.gen(function* () {140 Effect.gen(function* () {
113 const fetch = makeAuthFetch({141 const fetch = makeAuthFetch({
@@ -138,4 +166,93 @@ describe("Auth callback handlers", () => { · 24 unmodified lines
138 expect(setCookie).toContain("Max-Age=0");166 expect(setCookie).toContain("Max-Age=0");
139 }),167 }),
140 );168 );
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 );
141});258});
apps/cloud/src/auth/handlers.ts+126−16
@@ -110,11 +110,17 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 109 unmodified lines
110 const workos = yield* WorkOSAuth;110 const workos = yield* WorkOSAuth;
111 const users = yield* UserStoreService;111 const users = yield* UserStoreService;
112 const cookieState = request.cookies[STATE_COOKIE] ?? null;112 const cookieState = request.cookies[STATE_COOKIE] ?? null;
113 if (!cookieState || !timingSafeEqual(cookieState, query.state)) {113 // CSRF check is only enforced when the redirect carries a state
114 return deleteResponseCookie(114 // value — some WorkOS-initiated redirects don't include one.
115 HttpServerResponse.text("Invalid login state", { status: 400 }),115 // When state is present, it MUST match the cookie we set on
116 STATE_COOKIE,116 // /login.
117 );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 }
118 }124 }
119 125
120 const result = yield* workos.authenticateWithCode(query.code);126 const result = yield* workos.authenticateWithCode(query.code);
@@ -124,19 +130,24 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 3 unmodified lines
124 130
125 let sealedSession = result.sealedSession;131 let sealedSession = result.sealedSession;
126 132
127 // If the auth response didn't surface an org but the user already133 // If the auth response didn't surface an org but the user is already
128 // belongs to one, rehydrate the session with it. If they have no134 // an *active* member of one, rehydrate the session with it. Pending
129 // memberships at all, leave the session org-less — the frontend135 // memberships (which represent unaccepted invitations on WorkOS's
130 // AuthGate will render the onboarding flow. We never auto-create136 // side) are skipped — refreshing into one 400s, and silently
131 // organizations on login.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.
132 if (!result.organizationId && sealedSession) {141 if (!result.organizationId && sealedSession) {
133 const memberships = yield* workos.listUserMemberships(result.user.id);142 const memberships = yield* workos.listUserMemberships(result.user.id);
134 const existing = memberships.data[0];143 const existingActive = memberships.data.find((m) => m.status === "active");
135 if (existing) {144 if (existingActive) {
136 const refreshed = yield* workos.refreshSession(145 // Best-effort refresh — if WorkOS rejects (e.g. the membership
137 sealedSession,146 // was just revoked), fall through to an org-less session rather
138 existing.organizationId,147 // than 500ing the entire callback.
139 );148 const refreshed = yield* workos
149 .refreshSession(sealedSession, existingActive.organizationId)
150 .pipe(Effect.orElseSucceed(() => null));
140 if (refreshed) sealedSession = refreshed;151 if (refreshed) sealedSession = refreshed;
141 }152 }
142 }153 }
@@ -262,6 +273,105 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 119 unmodified lines
262 return yield* Effect.fail(new WorkOSError());273 return yield* Effect.fail(new WorkOSError());
263 }274 }
264 275
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
265 setCookie("wos-session", refreshed, COOKIE_OPTIONS);375 setCookie("wos-session", refreshed, COOKIE_OPTIONS);
266 return { id: org.id, name: org.name };376 return { id: org.id, name: org.name };
267 }),377 }),
apps/cloud/src/auth/workos.ts+8−0
@@ -185,6 +185,14 @@ const make = Effect.gen(function* () { · 184 unmodified lines
185 })),185 })),
186 ),186 ),
187 187
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
188 /** Remove an organization membership. */196 /** Remove an organization membership. */
189 deleteOrgMembership: (membershipId: string) =>197 deleteOrgMembership: (membershipId: string) =>
190 use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)),198 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
42export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");42export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
43export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");43export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
44 44
45export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
46 timeToLive: "1 minute",
47 reactivityKeys: [ReactivityKey.auth],
48});
49
50export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvitation");
51
45// ---------------------------------------------------------------------------52// ---------------------------------------------------------------------------
46// Provider + hook53// Provider + hook
47// ---------------------------------------------------------------------------54// ---------------------------------------------------------------------------
apps/cloud/src/web/pages/onboarding.tsx+278−32
@@ -1,14 +1,72 @@
1import { useState } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";
5import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
1import { Button } from "@executor-js/react/components/button";6import { Button } from "@executor-js/react/components/button";
7import { Skeleton } from "@executor-js/react/components/skeleton";
2 8
3import { AUTH_PATHS } from "../../auth/api";9import { AUTH_PATHS } from "../../auth/api";
4import { useAuth } from "../auth";10import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth";
5import {11import {
6 CreateOrganizationFields,12 CreateOrganizationFields,
7 useCreateOrganizationForm,13 useCreateOrganizationForm,
8} from "../components/create-organization-form";14} from "../components/create-organization-form";
9 15
16type PendingInvitation = {
17 id: string;
18 organizationId: string;
19 organizationName: string;
20 createdAt: string;
21 inviter: { email: string; name: string | null } | null;
22};
23
24const 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
10export const OnboardingPage = () => {42export const OnboardingPage = () => {
11 const auth = useAuth();43 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 };
12 70
13 const suggestedName =71 const suggestedName =
14 auth.status === "authenticated" &&72 auth.status === "authenticated" &&
@@ -19,38 +77,70 @@ export const OnboardingPage = () => { · 4 unmodified lines
19 77
20 const form = useCreateOrganizationForm({78 const form = useCreateOrganizationForm({
21 defaultName: suggestedName,79 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.
25 onSuccess: () => {},80 onSuccess: () => {},
26 });81 });
27 82
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
28 return (93 return (
29 <div className="flex min-h-screen items-center justify-center bg-background px-4">94 <div className="flex min-h-screen items-center justify-center bg-background px-4">
30 <div className="mx-auto flex w-full max-w-sm flex-col gap-6">95 <div className="mx-auto flex w-full max-w-sm flex-col gap-6">
31 <div className="flex flex-col gap-2">96 <header className="flex flex-col gap-2">
32 <h1 className="font-serif text-3xl">Create your organization</h1>97 <h1 className="font-serif text-3xl">
33 <p className="text-sm text-muted-foreground">98 {isLoading
34 Organizations group your sources, secrets, and teammates. You can invite others once99 ? "Loading"
35 it's set up.100 : count === 0
36 </p>101 ? "Create your organization"
37 </div>102 : "You've been invited"}
38 103 </h1>
39 <CreateOrganizationFields104 {!isLoading && count === 0 && (
40 name={form.name}105 <p className="text-sm text-muted-foreground">
41 onNameChange={(name) => {106 Organizations group your sources, secrets, and teammates. You can invite others once
42 form.setName(name);107 it's set up.
43 if (form.error) form.setError(null);108 </p>
44 }}109 )}
45 error={form.error}110 </header>
46 onSubmit={() => void form.submit()}111
47 />112 {isLoading && <InvitationsSkeleton />}
48 113
49 <div className="flex items-center justify-between gap-3">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">
50 {/* oxlint-disable-next-line react/forbid-elements */}140 {/* oxlint-disable-next-line react/forbid-elements */}
51 <button141 <button
52 type="button"142 type="button"
53 className="text-xs text-muted-foreground hover:text-foreground"143 className="text-xs text-muted-foreground transition-colors hover:text-foreground"
54 onClick={async () => {144 onClick={async () => {
55 await fetch(AUTH_PATHS.logout, { method: "POST" });145 await fetch(AUTH_PATHS.logout, { method: "POST" });
56 window.location.href = "/";146 window.location.href = "/";
@@ -58,15 +148,171 @@ export const OnboardingPage = () => { · 1 unmodified line
58 >148 >
59 Sign out149 Sign out
60 </button>150 </button>
61 <Button151 </footer>
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>
69 </div>152 </div>
70 </div>153 </div>
71 );154 );
72};155};
156
157// ---------------------------------------------------------------------------
158
159const InvitationsSkeleton = () => (
160 <div className="flex flex-col gap-2.5">
161 <InvitationRowSkeleton />
162 <InvitationRowSkeleton />
163 </div>
164);
165
166const 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
178const 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
201const 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
228const 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
272const 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