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.
Unified · Split
| @@ -43,9 +43,38 @@ const CreateOrganizationResponse = Schema.Struct({ · 42 unmodified lines | ||
| 43 | 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). | |
| 46 | 49 | const AuthCallbackSearch = Schema.Struct({ |
| 47 | 50 | 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, | |
| 49 | 78 | }); |
| 50 | 79 | |
| 51 | 80 | export const AUTH_PATHS = { |
| @@ -95,4 +124,17 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 43 unmodified lines | ||
| 95 | 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 | 140 | .middleware(SessionAuth) {} |
| @@ -7,6 +7,7 @@ import type { Effect as EffectType } from "effect/Effect"; · 6 unmodified lines | ||
| 7 | 7 | import { CloudAuthPublicApi } from "./api"; |
| 8 | 8 | import { CloudAuthPublicHandlers } from "./handlers"; |
| 9 | 9 | import { UserStoreService } from "./context"; |
| 10 | +import { WorkOSError } from "./errors"; | |
| 10 | 11 | import { WorkOSAuth } from "./workos"; |
| 11 | 12 | |
| 12 | 13 | const 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 | 139 | it.effect("sets the session cookie and clears login state on matching callback state", () => |
| 112 | 140 | Effect.gen(function* () { |
| 113 | 141 | const fetch = makeAuthFetch({ |
| @@ -138,4 +166,93 @@ describe("Auth callback handlers", () => { · 24 unmodified lines | ||
| 138 | 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 | }); |
| @@ -110,11 +110,17 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 109 unmodified lines | ||
| 110 | 110 | const workos = yield* WorkOSAuth; |
| 111 | 111 | const users = yield* UserStoreService; |
| 112 | 112 | 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 | + } | |
| 118 | 124 | } |
| 119 | 125 | |
| 120 | 126 | const result = yield* workos.authenticateWithCode(query.code); |
| @@ -124,19 +130,24 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 3 unmodified lines | ||
| 124 | 130 | |
| 125 | 131 | let sealedSession = result.sealedSession; |
| 126 | 132 | |
| 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. | |
| 132 | 141 | if (!result.organizationId && sealedSession) { |
| 133 | 142 | 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)); | |
| 140 | 151 | if (refreshed) sealedSession = refreshed; |
| 141 | 152 | } |
| 142 | 153 | } |
| @@ -262,6 +273,105 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 119 unmodified lines | ||
| 262 | 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 | 375 | setCookie("wos-session", refreshed, COOKIE_OPTIONS); |
| 266 | 376 | return { id: org.id, name: org.name }; |
| 267 | 377 | }), |
| @@ -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 | 196 | /** Remove an organization membership. */ |
| 189 | 197 | deleteOrgMembership: (membershipId: string) => |
| 190 | 198 | use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)), |
| @@ -42,6 +42,13 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 41 unmodified lines | ||
| 42 | 42 | export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); |
| 43 | 43 | export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); |
| 44 | 44 | |
| 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 | + | |
| 45 | 52 | // --------------------------------------------------------------------------- |
| 46 | 53 | // Provider + hook |
| 47 | 54 | // --------------------------------------------------------------------------- |
| @@ -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"; | |
| 1 | 6 | import { Button } from "@executor-js/react/components/button"; |
| 7 | +import { Skeleton } from "@executor-js/react/components/skeleton"; | |
| 2 | 8 | |
| 3 | 9 | import { AUTH_PATHS } from "../../auth/api"; |
| 4 | −import { useAuth } from "../auth"; | |
| 10 | +import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth"; | |
| 5 | 11 | import { |
| 6 | 12 | CreateOrganizationFields, |
| 7 | 13 | useCreateOrganizationForm, |
| 8 | 14 | } from "../components/create-organization-form"; |
| 9 | 15 | |
| 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 | + | |
| 10 | 42 | export const OnboardingPage = () => { |
| 11 | 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 | 71 | const suggestedName = |
| 14 | 72 | auth.status === "authenticated" && |
| @@ -19,38 +77,70 @@ export const OnboardingPage = () => { · 4 unmodified lines | ||
| 19 | 77 | |
| 20 | 78 | const form = useCreateOrganizationForm({ |
| 21 | 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 | 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 | 93 | return ( |
| 29 | 94 | <div className="flex min-h-screen items-center justify-center bg-background px-4"> |
| 30 | 95 | <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"> | |
| 50 | 140 | {/* oxlint-disable-next-line react/forbid-elements */} |
| 51 | 141 | <button |
| 52 | 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 | 144 | onClick={async () => { |
| 55 | 145 | await fetch(AUTH_PATHS.logout, { method: "POST" }); |
| 56 | 146 | window.location.href = "/"; |
| @@ -58,15 +148,171 @@ export const OnboardingPage = () => { · 1 unmodified line | ||
| 58 | 148 | > |
| 59 | 149 | Sign out |
| 60 | 150 | </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> | |
| 69 | 152 | </div> |
| 70 | 153 | </div> |
| 71 | 154 | ); |
| 72 | 155 | }; |
| 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 | +}; | |