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
| Piece | File | Responsibility |
|---|---|---|
AccountSwitcher | src/lib/users/AccountSwitcher.tsx | Captures current path + query, passes it as returnTo on the button's forceRedirectUrl |
| Provider default | src/app/[locale]/layout.tsx | signInForceRedirectUrl={/${locale}/event/sign-in} — fallback for sign-ins not started from AccountSwitcher (e.g. the inline <SignIn /> on /e) |
| Gate page | src/app/[locale]/event/sign-in/page.tsx | Validates returnTo, maps it by role, redirects; no UI |
isSafeReturnPath | src/lib/url/returnTo.ts | Open-redirect guard: only same-origin relative paths pass |
returnToRoleMap | src/lib/url/returnToRoleMap.ts | Pure view ↔ edit path translation |
Role mapping
returnToRoleMap(returnTo, role) translates the mode segment while always
preserving the locale prefix, query string, and hash:
| Current page | org:admin | member / no membership |
|---|---|---|
/{locale} (home) | /{locale}/e | unchanged |
/{locale}/v | /{locale}/e | /{locale} |
/{locale}/v/{id} | /{locale}/e/{id} | unchanged |
/{locale}/e | unchanged | /{locale} |
/{locale}/e/{id} (numeric id) | unchanged | /{locale}/v/{id} |
/{locale}/e/org…, /e/ou…, /e/tmp…, /e/new, /e/orgchange | unchanged | /{locale} (no view equivalent) |
| anything else (legal pages, docs, orglist, …) | unchanged | unchanged |
Details:
- Numeric-id rule — only a purely numeric segment directly after
/e/or/v/counts as an activity id, so/e/newis never mistaken for one. - Query params survive —
/and/eboth feedsearchParams(ou selection, filters,showInactive) intoMainContent, 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
AccountSwitcherreads the path withusePathnamefromnext/navigation(not the next-intl wrapper): the gate redirects server-side withredirect()fromnext/navigation, which needs the locale-prefixed path. The next-intlusePathnamestrips the locale and is only appropriate for locale-awareLink/routernavigation.- 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
| Area | File |
|---|---|
| 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 construction | src/lib/users/AccountSwitcher.test.tsx |
| Open-redirect guard | src/lib/url/returnTo.test.ts |
Related
- Clerk Integration — provider config, multitenancy
- Authorization — role model,
requireOrgAccess useReturnTo(src/lib/hooks/useReturnTo.ts) — the client-sidereturnToconvention for modal pages, which this flow reuses the guard from