Skip to main content

Post-Sign-In Redirect & returnTo Flow

Overview

Sign-in happens in a Clerk modal (SignInButton mode="modal" in src/lib/users/AccountSwitcher.tsx). After a successful sign-in the user is sent back to the page they were on, translated into the mode that matches their role:

  • org:admin → the edit-mode equivalent of the page (/v/123/e/123)
  • everyone else → the view-mode equivalent (/e/123/v/123)

The locale is always preserved — every redirect URL in the chain is built from the current [locale] segment, and the Clerk modal itself is localized via @clerk/localizations (see Clerk Integration).


Flow

User clicks sign-in (AccountSwitcher)
│ SignInButton forceRedirectUrl =
│ /{locale}/event/sign-in?returnTo=<encoded current path+query>

Clerk modal — user authenticates
│ component-level forceRedirectUrl takes precedence over the
│ provider-level signInForceRedirectUrl (Clerk redirect priority)

/[locale]/event/sign-in ← server-side gate, renders no UI
│ 1. getOrgMembership(shortName) role lookup
│ 2. isSafeReturnPath(returnTo) open-redirect guard
│ 3. returnToRoleMap(returnTo, role) view ↔ edit translation

redirect() to the mapped page

The pieces

PieceFileResponsibility
AccountSwitchersrc/lib/users/AccountSwitcher.tsxCaptures current path + query, passes it as returnTo on the button's forceRedirectUrl
Provider defaultsrc/app/[locale]/layout.tsxsignInForceRedirectUrl={/${locale}/event/sign-in} — fallback for sign-ins not started from AccountSwitcher (e.g. the inline <SignIn /> on /e)
Gate pagesrc/app/[locale]/event/sign-in/page.tsxValidates returnTo, maps it by role, redirects; no UI
isSafeReturnPathsrc/lib/url/returnTo.tsOpen-redirect guard: only same-origin relative paths pass
returnToRoleMapsrc/lib/url/returnToRoleMap.tsPure view ↔ edit path translation

Role mapping

returnToRoleMap(returnTo, role) translates the mode segment while always preserving the locale prefix, query string, and hash:

Current pageorg:adminmember / no membership
/{locale} (home)/{locale}/eunchanged
/{locale}/v/{locale}/e/{locale}
/{locale}/v/{id}/{locale}/e/{id}unchanged
/{locale}/eunchanged/{locale}
/{locale}/e/{id} (numeric id)unchanged/{locale}/v/{id}
/{locale}/e/org…, /e/ou…, /e/tmp…, /e/new, /e/orgchangeunchanged/{locale} (no view equivalent)
anything else (legal pages, docs, orglist, …)unchangedunchanged

Details:

  • Numeric-id rule — only a purely numeric segment directly after /e/ or /v/ counts as an activity id, so /e/new is never mistaken for one.
  • Query params survive/ and /e both feed searchParams (ou selection, filters, showInactive) into MainContent, so they carry over.
  • Demoting is safe — a member redirected toward a remembered admin URL gets the view equivalent; even if a mapped page still requires more rights, the destination enforces its own auth (requireOrgAccess), see Authorization.

Fallback

When returnTo is missing or rejected by isSafeReturnPath, the gate falls back to the mode home: org:admin/{locale}/e, everyone else → /{locale}. This is also the behavior for sign-ins that never pass through AccountSwitcher.


Locale handling

  • AccountSwitcher reads the path with usePathname from next/navigation (not the next-intl wrapper): the gate redirects server-side with redirect() from next/navigation, which needs the locale-prefixed path. The next-intl usePathname strips the locale and is only appropriate for locale-aware Link/router navigation.
  • The i18n routing uses localePrefix: "always" (default), so the pathname always starts with /{locale} and the gate path can be derived from it.

Security

The returnTo value is user-controllable (it is a query param), so the gate validates it with isSafeReturnPath before redirecting: absolute URLs, protocol-relative // values, scheme: values, and values with embedded whitespace are rejected. returnToRoleMap itself does not re-check origin safety — callers must validate first.


Deliberate non-goals

  • openSignIn() on the sign-out and org-not-available event pages intentionally keeps the role-based default: there is no meaningful "previous page" to return to in those contexts.
  • An admin signing in from a shared view link (/v/{id}) is mapped into the edit form for that activity — deep links deliberately follow the role-mapping rule rather than staying in view mode.

Tests

AreaFile
Path mapping (all table rows, both roles, query/hash)src/lib/url/returnToRoleMap.test.ts
Gate page (safe / unsafe / missing returnTo, roles)src/app/[locale]/event/sign-in/page.test.tsx
forceRedirectUrl constructionsrc/lib/users/AccountSwitcher.test.tsx
Open-redirect guardsrc/lib/url/returnTo.test.ts
  • Clerk Integration — provider config, multitenancy
  • Authorization — role model, requireOrgAccess
  • useReturnTo (src/lib/hooks/useReturnTo.ts) — the client-side returnTo convention for modal pages, which this flow reuses the guard from