Skip to content

Commit 19c3b6f

Browse files
feat(setup): setup wizard with browser-based Chat key handoff (#5911)
* feat(setup): setup wizard with browser-based Chat key handoff Adds `bun run setup` and `bun run doctor` for local installs, and replaces the wizard's paste-your-Chat-key step with a browser handoff that never puts the key in a URL. * improvement(setup): drop the paste-a-key fallback, simplify consent copy The browser handoff is now the only path — the wizard waits on a spinner instead of racing a paste prompt. Consent card leads with "Connect your terminal" and moves the match-the-code disclaimer into the description. * fix(setup): pin kube context, keep secrets out of argv, validate reused keys Review findings from #5911: - helm/kubectl now run against the validated context instead of the ambient one - helm values are piped on stdin rather than passed as --set arguments - ENCRYPTION_KEY/API_ENCRYPTION_KEY are checked for the 64-hex format the app requires, not just length, so an unusable key is replaced rather than kept - the managed Redis container's published port is read back instead of assumed * refactor(copilot): one module for Chat API key operations list/generate/delete each repeated the same /api/validate-key envelope in their route. They now share callValidateKey in lib/copilot/server/api-keys.ts, which also keeps the display masking server-side so the full key can only ever leave at creation. * improvement(setup): reuse shared helpers, parallelize probes, drop dead code - PKCE verifier/state/pairing code now use generateSecureToken, generateRandomHex and generateShortId instead of hand-rolled randomBytes; the pairing loop's modulo was unbiased only because 256 % 32 == 0 - new sha256Base64Url in @sim/security/hash so both sides of the PKCE exchange derive the challenge from one implementation - isUsableSecret moved beside SECRET_KEYS so setup and doctor apply the same rule; doctor previously passed a key setup would replace - isTruthy narrowed to true/1, matching the app it claims to mirror — it accepted yes/on, so a flag could read on in doctor and off in the app - checkLive runs its five probes concurrently (~17s serial worst case) - detection overlaps the banner animation instead of queueing behind it - glyph.fail/glyph.warn at 13 sites that bypassed the constant; removed unused prompter exports, a dead ENV_PATHS re-export, and an unused export keyword * fix(setup): make doctor understand the compose env layout Compose writes a single root .env (what docker-compose reads via env_file) but the checks required the three per-app files, so a successful compose install was followed by doctor printing three failures and exiting 1 — and the whole coherence catalog was skipped because it keyed off apps/sim/.env existing. Layout is now derived from what's on disk and every check consults it: file and schema checks iterate the layout's targets, consistency reports skip when there's only one file to mirror, and coherence/live read the layout's primary file. The wizard's existing-config detection counts root for the same reason — a compose install used to read as unconfigured and re-run from scratch. * feat(cli-auth): device-authorization poll flow, drop the loopback listener The CLI no longer binds a local port. It generates a request id + poll secret, opens /cli/auth, and polls /api/cli/auth/poll over TLS while the user approves in the browser — so the flow works over SSH and inside containers, where the browser and terminal don't share a machine. - approve stores the approval keyed by request id (session-authed, userId from the session only); poll verifies the secret before an atomic claim, so an observer of the semi-public request id can neither mint nor cancel it - pairing code stays as the anti-phishing compare; no key ever crosses the browser; done page just confirms - removes the loopback listener, /token exchange, buildCliHandoffUrl, and validateCliCallbackUrl (+ its tests) — nothing hands a key to a URL anymore * fix(setup): reuse an existing managed Postgres container instead of colliding A running sim-postgres fell through to `docker run --name sim-postgres` and died on the name conflict; a stopped one failed with "no DATABASE_URL to reach it" because the generated password only lived in the env files a fresh clone lacks. Both facts are recoverable from Docker: the ladder now reads the published port and password back via `docker inspect` and reuses the container (starting it if stopped). A container that won't answer prompts before recreating, and never drops the data volume silently. * improvement(setup): audience-first run-mode hints Each run mode now names who it's for — compose for self-hosting/evaluating, dev for contributing to Sim, k8s for rehearsing a production deploy — with the live detection state (Docker/kube/VM) appended. * fix(cli-auth): retry a failed mint, port container/port fixes to Redis + k8s Review findings from #5911: - poll now reserves the mint with an atomic NX lock instead of deleting the approval up front, so a failed mint (e.g. mothership blip) is retried by the next poll instead of forcing a fresh browser approval; the lock still prevents a double-mint and its TTL frees the slot if the caller dies - setup reuses/recreates an unhealthy managed sim-redis instead of colliding on the name (Redis has no data volume, so it removes and recreates without a prompt) - k8s failure-path hints carry --context, matching the success-path hints, so a changed ambient context can't send diagnostics to the wrong cluster - compose port-free waits for a killed port to actually release before re-checking; SIGKILL is async, so the immediate re-check re-saw the port * fix(setup): harden mint cleanup, Windows browser, container detection, helm cwd Review findings from #5911: - a post-mint completeApproval failure no longer routes into releaseMint — the mint lock now outlives the approval (shared TTL), so a cleanup blip can't leave a re-mintable window and orphan a key; cleanup is best-effort after the key ships - compose doctor --fix writes the feature-flag twin to the layout's primary env (root .env on a compose install), not always apps/sim/.env - Windows opens the browser via `cmd /c start "" <url>` — `start` is a shell builtin, so spawning it directly ENOENT'd and the handoff never opened - managed-container detection filters loosely and pins the exact name in code; Docker's `name=^x$` anchor matches the internal `/x` form and often missed, skipping the reuse branch - the shared helm/kind run helper pins cwd to the repo root, matching helm test, so `helm upgrade --install ./helm/sim` works from any working directory * feat(chat-keys): standalone manage page, drop from settings nav, refresh README - Add /account/settings/chat-keys — a linkable page to view, create, and revoke Chat API keys - Remove Chat keys from the settings sidebar (account + unified nav) and its render branches - README: replace Docker Compose + Manual Setup with the bun run setup wizard; drop the manual COPILOT_API_KEY step, point to the manage page * fix(setup): per-key reason in the secret-replacement warning Cursor: the warn hardcoded '64-character hex key', but only ENCRYPTION_KEY/API_ENCRYPTION_KEY require that — BETTER_AUTH_SECRET/INTERNAL_API_SECRET only need length >= 32. Use the existing secretRequirement(key) helper so each replaced key reports its actual requirement. * fix(setup): compose doctor schema, cross-platform binary detection, quoted context hints - Doctor: for the compose (root) env layout, require only the secrets compose has no interpolation default for (BETTER_AUTH_SECRET/ENCRYPTION_KEY/INTERNAL_API_SECRET). DATABASE_URL/BETTER_AUTH_URL/NEXT_PUBLIC_APP_URL come from docker-compose ${VAR:-default}, so a healthy compose install no longer fails doctor. - Binary detection: use Bun.which instead of which (which is absent on Windows), so kubectl/helm/kind/docker resolve cross-platform. - k8s diagnostic hints: POSIX-quote the kube-context so a context with whitespace/metacharacters can't break or inject into a copied command. * fix(setup): quote kube-context in the helm uninstall tear-down hint too The tear-down hint used --kube-context ${context} raw while the sibling kubectl hints already used shq(); a context with whitespace/metacharacters could break or inject into the copied command. All copyable k8s hints now go through shq(context). * feat(setup): sim lifecycle CLI — start/stop/status/logs/down/reset Turn the setup entry into a 'sim' command umbrella so there's one place to run everything, not scattered docker/bun commands. Adds a global bin (bun link) + a bun run sim fallback. - Detects how you're running (compose file / managed dev containers / helm release) from disk + docker/helm state — no persisted mode. Ambiguous installs prompt. - start/stop/restart/logs work per mode; down removes containers (volumes kept); reset archives .env + wipes managed data; both destructive verbs confirm first. - status shows detected mode, container states, and app/realtime health. - Wizard outro + README now point at the sim commands and the one-time bun link. * feat(setup): 'bun run sim' is the primary entry; bare invocation prints help - Lead usage/wizard-outro/README with 'bun run sim <cmd>' (works with zero PATH setup); global bare 'sim' via bun link is an optional upgrade, with the ~/.bun/bin PATH caveat spelled out (Homebrew's bun omits it). - Bare 'sim' now prints help instead of launching the wizard; the wizard is 'sim setup'. The 'setup' npm script passes the keyword so 'bun run setup' is unchanged. * fix(setup): quote the auth URL for cmd /c start on Windows Cursor (High): cmd re-parses the command line and treats & in the query string as a command separator, so cmd /c start opened a URL truncated at the first &, breaking the key flow on win32 (the handoff URL always has request/challenge/pairing). Quote the URL and pass args verbatim so & stays literal. * fix(setup): verify kube-context is really local; lengthen CLI handoff wait - k8s: a context named like a local cluster (kind-*, docker-desktop) can actually point at a remote API server. Verify the server host is loopback/docker-internal before defaulting the 'use this context?' confirm to yes; otherwise warn and default to no, so generated secrets can't ship to a remote cluster on a blind Enter. - cli-auth: bump the device-flow wait from 3 to 15 minutes so first-time users have time to sign up, wait for the email OTP, and approve before the terminal stops polling. The server-side approval record keeps its own short TTL, so a longer client wait only costs cheap rate-limited polls. * fix(setup): only manage k8s lifecycle on a verified-local context Greptile: sim down/reset used the ambient kube-context, so switching context after setup could uninstall a same-named sim-dev release from the wrong cluster. Gate k8sInstall on the same locality check the wizard uses (API server is loopback/docker-internal) via a shared isLocalKubeContext helper — the wizard only ever deploys locally, so a remote current-context is never treated as a Sim install. * fix(setup): doctor skips placeholder secrets when seeding; reset names its target - checks: the missing-file autofix copied shared keys from apps/sim/.env whenever truthy, including .env.example placeholders — doctor --fix could seed unusable secrets into realtime/db env files. Skip placeholders, matching autofixForMissing. - lifecycle: reset now names the exact install (k8s context / compose file / dev containers) in its confirm, so a destructive reset can't silently hit the wrong same-named install after a context switch (down already names the context). * fix(cli-auth): size the poll rate limit to the poll cadence; honor Retry-After The poll route used the default public-IP bucket (10 burst, 5/min) but the CLI polls every 2s (30/min), so it 429'd within ~20s — worse behind a slow dev cold-compile. Give the endpoint a bucket matched to its cadence (60 burst, 60/min); it's not a brute-force surface (unknown request id returns pending, minting needs the 256-bit verifier). Also make the CLI honor Retry-After and back off on 429 so a shared-NAT per-IP limit degrades gracefully instead of hammering. * fix(setup): check ports before starting the dev server, not just compose Local dev auto-start spawned bun run dev:full with no port check, so it silently started a server that couldn't bind when 3000/3002 were already taken (e.g. another worktree's dev server). Extract compose's port-conflict resolver into a shared ensurePortsFree(ports) and run it before the dev start too — kill/recheck/leave, same as compose. Leaving the ports skips the auto-start with guidance instead of failing; compose still treats it as fatal. * fix(setup): verify the kube cluster is reachable, not just local A kubeconfig context can outlive its cluster — a kind cluster gets deleted or its Docker container stops (Docker/machine restart), but the context entry remains, pointing at a dead API-server port. The wizard checked the context looked local and handed it to helm, which failed with 'cluster unreachable'. Add a clusterReachable() liveness probe: only offer the current context when it actually answers; if a local context is dead, fall through to the kind path. There, if kind still knows 'sim' but it's stopped, start its node containers and wait for the API; if it's gone, create fresh. Either way the user gets a working cluster instead of a cryptic helm failure. * fix(helm): point appVersion at published image tags (v-prefixed, current) The chart's appVersion was "0.6.73", but CI publishes GHCR tags with a v prefix (its release-commit regex captures v0.7.45). Since sim.image defaults every image tag to Chart.AppVersion, a default helm install requested ghcr.io/simstudioai/{simstudio,realtime,migrations}:0.6.73 — a tag that has never existed — so app and realtime sat in ImagePullBackOff and helm --wait failed with 'progress deadline exceeded'. Any self-hoster installing with default values hit this, not just the setup wizard. Set appVersion to v0.7.45 (latest release on main; all three images verified present on ghcr) and bump the chart version to 1.1.1. Verified with helm lint, helm template (all images render as v0.7.45), and a live helm upgrade on a kind cluster where the new pods pull successfully while the old 0.6.73 pods remain in ImagePullBackOff. * Revert "fix(helm): point appVersion at published image tags (v-prefixed, current)" This reverts commit 28b6047. * chore(api-validation): rebaseline route count to 977 after staging merge Staging moved the baseline to 975; this branch's two CLI-auth routes (approve, poll) make 977. The clean merge absorbed the earlier +2 adjustment. * fix(settings): don't highlight a sibling nav item on nested settings pages /account/settings/chat-keys is a real page but deliberately not a nav item, so the sidebar's parseSettingsPathSection fell through to defaultSection ('general') and highlighted General — the page read as though it lived inside General. Resolve the sidebar's active item with a null default so an unmatched nested route highlights nothing, and widen SettingsSidebar's activeSection to string | null. The section feeding the title/description provider keeps its default (pages override title/description anyway), and /account/settings/billing/credit-usage still correctly highlights Billing. * fix(setup,auth): manage explicitly-confirmed k8s contexts, fail loudly on reset, clear stale post-auth redirect - lifecycle: detection is now factual — a sim-dev release either exists on the current context or it doesn't. Gating on locality stranded a release the user explicitly confirmed during setup (status/start/stop/down/reset all claimed no k8s install). Locality is recorded instead and surfaced through describeInstall, which every destructive confirm renders, so acting on a non-local cluster is named and defaulted to no rather than silently blocked or silently allowed. - lifecycle: reset no longer discards helm uninstall's exit status. Env files are archived by that point, so claiming 'Reset complete' while the release still runs is the worst outcome — it now throws with retry/inspect commands. - auth: signup clears POST_AUTH_REDIRECT_STORAGE_KEY when it has no callbackUrl, and the verification-disabled path consumes it, so a stale CLI/invite destination can't leak into a later flow in the same tab.
1 parent ca77908 commit 19c3b6f

65 files changed

Lines changed: 5003 additions & 305 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -75,71 +75,42 @@ Docker must be installed and running. Use `-p, --port <port>` to run Sim on a di
7575

7676
## Self-hosting
7777

78-
### Docker Compose
78+
**Requirements:** [Bun](https://bun.sh/) and [Docker](https://www.docker.com/).
7979

8080
```bash
8181
git clone https://github.com/simstudioai/sim.git && cd sim
82-
docker compose -f docker-compose.prod.yml up -d
83-
```
84-
85-
Open [http://localhost:3000](http://localhost:3000)
86-
87-
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/). See the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
88-
89-
### Manual Setup
90-
91-
**Requirements:** [Bun](https://bun.sh/), [Node.js](https://nodejs.org/) v20+, PostgreSQL 12+ with [pgvector](https://github.com/pgvector/pgvector)
92-
93-
1. Clone and install:
94-
95-
```bash
96-
git clone https://github.com/simstudioai/sim.git
97-
cd sim
9882
bun install
99-
bun run prepare # Set up pre-commit hooks
83+
bun run setup
10084
```
10185

102-
2. Set up PostgreSQL with pgvector:
86+
`bun run setup` is an interactive wizard: it provisions the database, generates secrets, writes your `.env` files, connects a Chat API key, and starts Sim the way you choose:
10387

104-
```bash
105-
docker run --name simstudio-db -e POSTGRES_PASSWORD=your_password -e POSTGRES_DB=simstudio -p 5432:5432 -d pgvector/pgvector:pg17
106-
```
88+
- **Local dev** — run from source to contribute or hack on Sim
89+
- **Docker Compose** — a self-contained instance for testing self-hosting
90+
- **Kubernetes (Helm)** — deploy to a local cluster
10791

108-
Or install manually via the [pgvector guide](https://github.com/pgvector/pgvector#installation).
92+
When it finishes, open [http://localhost:3000](http://localhost:3000).
10993

110-
3. Configure environment:
94+
Manage your install with `bun run sim`:
11195

11296
```bash
113-
cp apps/sim/.env.example apps/sim/.env
114-
# Create your secrets
115-
perl -i -pe "s/your_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
116-
perl -i -pe "s/your_internal_api_secret/$(openssl rand -hex 32)/" apps/sim/.env
117-
perl -i -pe "s/your_api_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
118-
# DB configs for migration
119-
cp packages/db/.env.example packages/db/.env
120-
# Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
97+
bun run sim start | stop | restart # bring your install up / down / cycle
98+
bun run sim status # what's installed and healthy
99+
bun run sim logs # follow logs
100+
bun run sim doctor # diagnose configuration problems
101+
bun run sim down # remove containers (data kept)
102+
bun run sim reset # archive .env and wipe managed data
121103
```
122104

123-
4. Run migrations:
105+
`sim` detects how you're running (Docker Compose, local dev, or Kubernetes) and acts accordingly.
124106

125-
```bash
126-
cd packages/db && bun run db:migrate
127-
```
107+
Prefer a bare `sim`? Run `bun link` once — but note `sim` lands in `~/.bun/bin`, which Homebrew's bun doesn't add to your PATH, so you may need `export PATH="$HOME/.bun/bin:$PATH"` in your shell profile.
128108

129-
5. Start development servers:
130-
131-
```bash
132-
bun run dev:full # Starts Next.js app and realtime socket server
133-
```
134-
135-
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
109+
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/). See the [self-hosting docs](https://docs.sim.ai/self-hosting/docker) for details.
136110

137111
## Chat API Keys
138112

139-
Chat is a Sim-managed service. To use Chat on a self-hosted instance:
140-
141-
- Go to https://sim.ai → Settings → Chat keys and generate a Chat API key
142-
- Set `COPILOT_API_KEY` environment variable in your self-hosted apps/sim/.env file to that value
113+
Chat is a Sim-managed service. `bun run setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/account/settings/chat-keys](https://sim.ai/account/settings/chat-keys).
143114

144115
## Environment Variables
145116

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Where the user goes once authentication finishes, carried across the login →
3+
* signup → verify hops. Written only after `validateCallbackUrl` accepts it, and
4+
* re-validated on read.
5+
*/
6+
export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl'
7+
8+
interface AuthCrossLinkParams {
9+
/** Validated post-auth destination to carry over, or null to drop it. */
10+
callbackUrl: string | null
11+
isInviteFlow: boolean
12+
}
13+
14+
/**
15+
* Builds the login ⇄ signup cross-link, preserving the post-auth destination so
16+
* a visitor who signs up instead of signing in still lands where they were
17+
* headed. `URLSearchParams` does the encoding — a destination that carries its
18+
* own query string (`/cli/auth?callback=…&state=…`) must survive intact.
19+
*/
20+
export function buildAuthCrossLink(
21+
path: '/login' | '/signup',
22+
{ callbackUrl, isInviteFlow }: AuthCrossLinkParams
23+
): string {
24+
const params = new URLSearchParams()
25+
if (isInviteFlow) params.set('invite_flow', 'true')
26+
if (callbackUrl) params.set('callbackUrl', callbackUrl)
27+
28+
const query = params.toString()
29+
return query ? `${path}?${query}` : path
30+
}

apps/sim/app/(auth)/login/login-form.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation'
2121
import { getBaseUrl } from '@/lib/core/utils/urls'
2222
import { quickValidateEmail } from '@/lib/messaging/email/validation'
2323
import { captureClientEvent } from '@/lib/posthog/client'
24+
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
2425
import {
2526
AuthDivider,
2627
AuthField,
@@ -107,6 +108,10 @@ export default function LoginPage({
107108
}
108109
const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace'
109110
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
111+
const signupHref = buildAuthCrossLink('/signup', {
112+
callbackUrl: isValidCallbackUrl ? callbackUrl : null,
113+
isInviteFlow,
114+
})
110115

111116
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
112117
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
@@ -431,11 +436,7 @@ export default function LoginPage({
431436
)}
432437

433438
{emailEnabled && (
434-
<AuthNavPrompt
435-
prompt="Don't have an account?"
436-
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
437-
linkLabel='Sign up'
438-
/>
439+
<AuthNavPrompt prompt="Don't have an account?" href={signupHref} linkLabel='Sign up' />
439440
)}
440441

441442
<AuthLegalFooter action='signing in' />

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
1010
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
1111
import { quickValidateEmail } from '@/lib/messaging/email/validation'
1212
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
13+
import { buildAuthCrossLink, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
1314
import {
1415
AuthDivider,
1516
AuthField,
@@ -343,9 +344,12 @@ function SignupFormContent({
343344

344345
if (typeof window !== 'undefined') {
345346
sessionStorage.setItem('verificationEmail', emailValue)
346-
if (isInviteFlow && redirectUrl) {
347-
sessionStorage.setItem('inviteRedirectUrl', redirectUrl)
348-
sessionStorage.setItem('isInviteFlow', 'true')
347+
if (redirectUrl) {
348+
sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl)
349+
} else {
350+
// Clear any leftover from an earlier signup in this tab — otherwise a
351+
// signup with no callbackUrl inherits the previous CLI/invite destination.
352+
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
349353
}
350354
}
351355

@@ -468,7 +472,7 @@ function SignupFormContent({
468472

469473
<AuthNavPrompt
470474
prompt='Already have an account?'
471-
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
475+
href={buildAuthCrossLink('/login', { callbackUrl: redirectUrl || null, isInviteFlow })}
472476
linkLabel='Sign in'
473477
/>
474478

apps/sim/app/(auth)/verify/use-verification.ts

Lines changed: 58 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,39 @@ import { normalizeEmail } from '@sim/utils/string'
66
import { useRouter, useSearchParams } from 'next/navigation'
77
import { client, useSession } from '@/lib/auth/auth-client'
88
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
9+
import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
910

1011
const logger = createLogger('useVerification')
1112

13+
/**
14+
* Resolves the post-auth destination at the moment of redirect rather than
15+
* caching it in state.
16+
*
17+
* Both redirect sites run in the same commit as the effect that reads session
18+
* storage, so a cached value is still `null` when they fire and the stored
19+
* destination is silently replaced by `/workspace`. Reading here removes that
20+
* race. `redirectAfter` wins over the stored URL; anything failing callback
21+
* validation is discarded, and an unsafe stored value is evicted.
22+
*/
23+
function resolveRedirectUrl(redirectParam: string | null): string | null {
24+
let resolved: string | null = null
25+
26+
const stored = sessionStorage.getItem(POST_AUTH_REDIRECT_STORAGE_KEY)
27+
if (stored && validateCallbackUrl(stored)) {
28+
resolved = stored
29+
} else if (stored) {
30+
logger.warn('Ignoring unsafe stored post-auth redirect URL', { url: stored })
31+
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
32+
}
33+
34+
if (redirectParam) {
35+
if (validateCallbackUrl(redirectParam)) resolved = redirectParam
36+
else logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
37+
}
38+
39+
return resolved
40+
}
41+
1242
/**
1343
* Mutually-exclusive phases of the email-OTP verification machine.
1444
* - `idle`: awaiting input
@@ -53,44 +83,11 @@ export function useVerification({
5383
const [isResending, setIsResending] = useState(false)
5484
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
5585
const [errorMessage, setErrorMessage] = useState('')
56-
const [redirectUrl, setRedirectUrl] = useState<string | null>(null)
57-
const [isInviteFlow, setIsInviteFlow] = useState(false)
5886

5987
useEffect(() => {
60-
if (typeof window !== 'undefined') {
61-
const storedEmail = sessionStorage.getItem('verificationEmail')
62-
if (storedEmail) {
63-
setEmail(storedEmail)
64-
}
65-
66-
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
67-
if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) {
68-
setRedirectUrl(storedRedirectUrl)
69-
} else if (storedRedirectUrl) {
70-
logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl })
71-
sessionStorage.removeItem('inviteRedirectUrl')
72-
}
73-
74-
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
75-
if (storedIsInviteFlow === 'true') {
76-
setIsInviteFlow(true)
77-
}
78-
}
79-
80-
const redirectParam = searchParams.get('redirectAfter')
81-
if (redirectParam) {
82-
if (validateCallbackUrl(redirectParam)) {
83-
setRedirectUrl(redirectParam)
84-
} else {
85-
logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
86-
}
87-
}
88-
89-
const inviteFlowParam = searchParams.get('invite_flow')
90-
if (inviteFlowParam === 'true') {
91-
setIsInviteFlow(true)
92-
}
93-
}, [searchParams])
88+
const storedEmail = sessionStorage.getItem('verificationEmail')
89+
if (storedEmail) setEmail(storedEmail)
90+
}, [])
9491

9592
useEffect(() => {
9693
if (email && !isSendingInitialOtp && hasEmailService) {
@@ -122,21 +119,12 @@ export function useVerification({
122119
logger.warn('Failed to refetch session after verification', e)
123120
}
124121

125-
if (typeof window !== 'undefined') {
126-
sessionStorage.removeItem('verificationEmail')
127-
128-
if (isInviteFlow) {
129-
sessionStorage.removeItem('inviteRedirectUrl')
130-
sessionStorage.removeItem('isInviteFlow')
131-
}
132-
}
122+
const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) ?? '/workspace'
123+
sessionStorage.removeItem('verificationEmail')
124+
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
133125

134126
setTimeout(() => {
135-
if (isInviteFlow && redirectUrl) {
136-
window.location.href = redirectUrl
137-
} else {
138-
window.location.href = '/workspace'
139-
}
127+
window.location.href = destination
140128
}, 1000)
141129
} else {
142130
logger.info('Setting invalid OTP state - API error response')
@@ -217,28 +205,31 @@ export function useVerification({
217205
}, [otp, email, status, isResending])
218206

219207
useEffect(() => {
220-
if (typeof window !== 'undefined') {
221-
if (!isEmailVerificationEnabled) {
222-
setStatus('verified')
208+
if (isEmailVerificationEnabled) return
223209

224-
const handleRedirect = async () => {
225-
try {
226-
await refetchSession()
227-
} catch (error) {
228-
logger.warn('Failed to refetch session during verification skip:', error)
229-
}
230-
231-
if (isInviteFlow && redirectUrl) {
232-
window.location.href = redirectUrl
233-
} else {
234-
router.push('/workspace')
235-
}
236-
}
210+
setStatus('verified')
237211

238-
handleRedirect()
212+
const destination = resolveRedirectUrl(searchParams.get('redirectAfter'))
213+
// Single-use: consume the stored destination here too, or it survives this
214+
// flow and reapplies to a later sign-in in the same tab.
215+
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
216+
217+
const handleRedirect = async () => {
218+
try {
219+
await refetchSession()
220+
} catch (error) {
221+
logger.warn('Failed to refetch session during verification skip:', error)
222+
}
223+
224+
if (destination) {
225+
window.location.href = destination
226+
} else {
227+
router.push('/workspace')
239228
}
240229
}
241-
}, [isEmailVerificationEnabled, router, isInviteFlow, redirectUrl])
230+
231+
handleRedirect()
232+
}, [isEmailVerificationEnabled, router, searchParams])
242233

243234
return {
244235
otp,

apps/sim/app/(auth)/verify/verify-content.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { Suspense, useEffect, useState } from 'react'
44
import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
5+
import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
56
import {
67
AuthFormMessage,
78
AuthHeader,
@@ -140,8 +141,7 @@ function VerificationForm({
140141
onNavigate={() => {
141142
if (typeof window !== 'undefined') {
142143
sessionStorage.removeItem('verificationEmail')
143-
sessionStorage.removeItem('inviteRedirectUrl')
144-
sessionStorage.removeItem('isInviteFlow')
144+
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
145145
}
146146
}}
147147
/>

apps/sim/app/account/settings/[section]/page.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
parseSettingsPathSection,
1111
} from '@/components/settings/navigation'
1212
import { getSession } from '@/lib/auth'
13-
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
13+
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1414
import { isPlatformAdmin } from '@/lib/permissions/super-user'
1515

1616
interface AccountSettingsSectionPageProps {
@@ -46,7 +46,6 @@ export default async function AccountSettingsSectionPage({
4646
})
4747
if (!parsed) notFound()
4848
if (parsed === 'billing' && !isBillingEnabled) redirect(getAccountSettingsHref('general'))
49-
if (parsed === 'copilot' && !isHosted) redirect(getAccountSettingsHref('general'))
5049
if (parsed === 'admin' || parsed === 'mothership') {
5150
const isSuperUser = await isPlatformAdmin(session.user.id)
5251
if (!isSuperUser) notFound()

0 commit comments

Comments
 (0)