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 | 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). | ||
| 46 | const AuthCallbackSearch = Schema.Struct({ | 49 | const AuthCallbackSearch = Schema.Struct({ |
| 47 | code: Schema.String, | 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 | export const AUTH_PATHS = { | 80 | export 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) {} |
| @@ -7,6 +7,7 @@ import type { Effect as EffectType } from "effect/Effect"; · 6 unmodified lines | |||
| 7 | import { CloudAuthPublicApi } from "./api"; | 7 | import { CloudAuthPublicApi } from "./api"; |
| 8 | import { CloudAuthPublicHandlers } from "./handlers"; | 8 | import { CloudAuthPublicHandlers } from "./handlers"; |
| 9 | import { UserStoreService } from "./context"; | 9 | import { UserStoreService } from "./context"; |
| 10 | import { WorkOSError } from "./errors"; | ||
| 10 | import { WorkOSAuth } from "./workos"; | 11 | import { WorkOSAuth } from "./workos"; |
| 11 | 12 | ||
| 12 | const TestAuthPublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi); | 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 | 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 | }); |
| @@ -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 already | 133 | // 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 no | 134 | // an *active* member of one, rehydrate the session with it. Pending |
| 129 | // memberships at all, leave the session org-less — the frontend | 135 | // memberships (which represent unaccepted invitations on WorkOS's |
| 130 | // AuthGate will render the onboarding flow. We never auto-create | 136 | // 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 | }), |
| @@ -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)), |
| @@ -42,6 +42,13 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 41 unmodified lines | |||
| 42 | export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); | 42 | export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); |
| 43 | export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); | 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 | // Provider + hook | 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 | import { Button } from "@executor-js/react/components/button"; | 6 | import { Button } from "@executor-js/react/components/button"; |
| 7 | import { Skeleton } from "@executor-js/react/components/skeleton"; | ||
| 2 | 8 | ||
| 3 | import { AUTH_PATHS } from "../../auth/api"; | 9 | import { AUTH_PATHS } from "../../auth/api"; |
| 4 | import { useAuth } from "../auth"; | 10 | import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth"; |
| 5 | import { | 11 | import { |
| 6 | CreateOrganizationFields, | 12 | CreateOrganizationFields, |
| 7 | useCreateOrganizationForm, | 13 | useCreateOrganizationForm, |
| 8 | } from "../components/create-organization-form"; | 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 | export const OnboardingPage = () => { | 42 | export 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 once | 99 | ? "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 | <CreateOrganizationFields | 104 | {!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 | <button | 141 | <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 out | 149 | Sign out |
| 60 | </button> | 150 | </button> |
| 61 | <Button | 151 | </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 | |||
| 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 | }; | ||