Skip to content

Commit f4cc8d7

Browse files
authored
v0.7.66: provenance logs, nuqs consolidation
2 parents cb28d14 + 90a76dd commit f4cc8d7

75 files changed

Lines changed: 2098 additions & 667 deletions

File tree

Some content is hidden

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

.claude/rules/sim-url-state.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Put state in the URL **only** when it is *all* of: shareable, deep-linkable, boo
3434
## Anti-patterns (forbidden)
3535

3636
- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
37-
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state.
37+
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. **If the target path equals the current path, it is a query mutation, not a navigation** — even when written as a full path template. Re-serializing the path by hand is lossy by construction: it drops every param the template forgets. Use the nuqs setter (`setParams({ key: null }, { history: 'replace', scroll: false })`) — `null` always removes the key, and only the params you name are touched. Both options are already nuqs defaults (see "Conventions"); write them explicitly because a group whose shared options set `history: 'push'` (e.g. `filesUrlKeys`) would otherwise push a back-stack entry for a strip.
3838
- `window.history.replaceState`/`pushState` to mutate a param.
3939
- Duplicating URL state into a store and syncing it with effects / `popstate` listeners.
4040
- High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs).
@@ -44,7 +44,7 @@ These reads/mutations are **not** anti-patterns and stay as-is:
4444

4545
- **Outbound URL builders**`new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination.
4646
- **Route navigations**`router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`.
47-
- **Read-once auth / redirect signals**`token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`.
47+
- **Read-once auth / redirect signals**`token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `new` (invite signup flow), `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. Key names are per-surface: files' `new` is a genuine nuqs param (`files/search-params.ts`), while invite's `new` is a one-shot signup signal.
4848

4949
## Per-feature `search-params.ts` — single source of truth
5050

@@ -128,7 +128,22 @@ If a client param must be re-read server-side after a change, set `shallow: fals
128128

129129
## Suspense boundary
130130

131-
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`.
131+
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame.
132+
133+
**Never `fallback={null}` on a page entry.** The route's co-located `loading.tsx` default export *is* the correct fallback — one skeleton serves both the route-level navigation transition (which Next renders automatically) and the in-page suspend (which this boundary renders). If the segment has no `loading.tsx`, add one; the route transition needs it anyway. Import it absolutely (`sim-imports.md`):
134+
135+
```typescript
136+
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
137+
import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading'
138+
139+
<Suspense fallback={<KnowledgeBaseLoading />}>
140+
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
141+
</Suspense>
142+
```
143+
144+
Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.
145+
146+
This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".
132147

133148
## Debounced text inputs
134149

apps/docs/content/docs/en/platform/self-hosting/authentication.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th
8484

8585
| Variable | Effect |
8686
|---|---|
87-
| `DISABLE_REGISTRATION=true` | Blocks email/password registration |
87+
| `DISABLE_REGISTRATION=true` | Blocks all new accounts — email/password, email OTP, and social sign-in. Only existing accounts can sign in, including to accept a workspace invitation. SSO is unaffected |
8888
| `DISABLE_EMAIL_SIGNUP=true` | Blocks new email/password registrations; existing email login keeps working |
8989
| `ALLOWED_LOGIN_DOMAINS` | Comma-separated domain allowlist, e.g. `acme.com,acme.co.uk`. Gates email sign-**in** as well as signup |
9090
| `ALLOWED_LOGIN_EMAILS` | Comma-separated address allowlist, applied the same way |
@@ -93,7 +93,9 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th
9393
| `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above |
9494

9595
<Callout type="warn">
96-
These controls gate the **email/password** path. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. If you need a hard boundary, disable the social providers you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO.
96+
`ALLOWED_LOGIN_DOMAINS`, `ALLOWED_LOGIN_EMAILS`, and `SIGNUP_MX_VALIDATION_ENABLED` gate the **email/password** path only. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. To restrict who may sign in through a social provider, disable the ones you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO.
97+
98+
`DISABLE_REGISTRATION` and `BLOCKED_SIGNUP_DOMAINS` apply to every path, social included.
9799
</Callout>
98100

99101
For a company deployment, the usual pairing is domain-restricted signup plus SSO:

apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ See [Authentication](/platform/self-hosting/authentication).
107107

108108
| Variable | Description |
109109
|----------|-------------|
110-
| `DISABLE_REGISTRATION` | Set `true` to disable new user signups entirely |
110+
| `DISABLE_REGISTRATION` | Set `true` to block all new accounts, including social sign-in. Invitations still work for people who already have an account. SSO is unaffected |
111111
| `DISABLE_EMAIL_SIGNUP` | Block new email/password registrations; existing email login keeps working |
112112
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
113113
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |

apps/sim/app/(auth)/auth-redirect.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect'
5+
import {
6+
buildAuthCrossLink,
7+
resolveAuthRedirect,
8+
resolvePostSignupDestination,
9+
} from '@/app/(auth)/auth-redirect'
610

711
describe('resolvePostSignupDestination', () => {
812
it('routes to the verify hop when verification is enforceable', () => {
@@ -56,4 +60,50 @@ describe('buildAuthCrossLink', () => {
5660
'/signup'
5761
)
5862
})
63+
64+
it('marks a new user so the invite page leads with account creation', () => {
65+
expect(
66+
buildAuthCrossLink('/signup', {
67+
callbackUrl: '/invite/abc',
68+
isInviteFlow: true,
69+
isNewUser: true,
70+
})
71+
).toBe('/signup?invite_flow=true&callbackUrl=%2Finvite%2Fabc&new=true')
72+
})
73+
74+
it('omits the new-user marker by default', () => {
75+
expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: true })).not.toContain(
76+
'new=true'
77+
)
78+
})
79+
})
80+
81+
describe('resolveAuthRedirect', () => {
82+
const NONE = { redirect: null, callbackUrl: null, inviteFlow: null }
83+
84+
it('prefers redirect over callbackUrl', () => {
85+
expect(resolveAuthRedirect({ ...NONE, redirect: '/a', callbackUrl: '/b' }).rawCallbackUrl).toBe(
86+
'/a'
87+
)
88+
})
89+
90+
it('falls through an empty redirect to callbackUrl', () => {
91+
expect(
92+
resolveAuthRedirect({ ...NONE, redirect: '', callbackUrl: '/invite/abc' }).rawCallbackUrl
93+
).toBe('/invite/abc')
94+
})
95+
96+
it('reports no destination when nothing was carried', () => {
97+
expect(resolveAuthRedirect(NONE)).toEqual({ rawCallbackUrl: '', isInviteFlow: false })
98+
})
99+
100+
it('treats an invitation destination as an invite flow without the flag', () => {
101+
expect(resolveAuthRedirect({ ...NONE, callbackUrl: '/invite/abc' }).isInviteFlow).toBe(true)
102+
})
103+
104+
it('honors the explicit flag when the destination is unrelated', () => {
105+
expect(
106+
resolveAuthRedirect({ ...NONE, callbackUrl: '/workspace', inviteFlow: 'true' }).isInviteFlow
107+
).toBe(true)
108+
})
59109
})

apps/sim/app/(auth)/auth-redirect.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,43 @@ export function resolvePostSignupDestination({
4343
return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' }
4444
}
4545

46+
/** The raw redirect-carrying params, as read from a URL on client or server. */
47+
interface AuthRedirectParams {
48+
redirect: string | null
49+
callbackUrl: string | null
50+
inviteFlow: string | null
51+
}
52+
53+
/**
54+
* The post-auth destination a visitor arrived with, and whether they are mid
55+
* invitation.
56+
*
57+
* `redirect` wins over `callbackUrl` — both spellings are in circulation. The
58+
* invite flow is inferred from the destination as well as the explicit flag, so
59+
* a link that lost `invite_flow` still reads as an invitation.
60+
*
61+
* Shared so the signup form and the registration-disabled page cannot drift on
62+
* which param wins; both feed the result to {@link buildAuthCrossLink}. The
63+
* caller validates — this function does not, so that a client can log the
64+
* rejection it already reports.
65+
*/
66+
export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthRedirectParams): {
67+
rawCallbackUrl: string
68+
isInviteFlow: boolean
69+
} {
70+
const rawCallbackUrl = redirect || callbackUrl || ''
71+
return {
72+
rawCallbackUrl,
73+
isInviteFlow: inviteFlow === 'true' || rawCallbackUrl.startsWith('/invite/'),
74+
}
75+
}
76+
4677
interface AuthCrossLinkParams {
4778
/** Validated post-auth destination to carry over, or null to drop it. */
4879
callbackUrl: string | null
4980
isInviteFlow: boolean
81+
/** Marks the visitor as new so the invite page leads with account creation. */
82+
isNewUser?: boolean
5083
}
5184

5285
/**
@@ -57,11 +90,12 @@ interface AuthCrossLinkParams {
5790
*/
5891
export function buildAuthCrossLink(
5992
path: '/login' | '/signup',
60-
{ callbackUrl, isInviteFlow }: AuthCrossLinkParams
93+
{ callbackUrl, isInviteFlow, isNewUser = false }: AuthCrossLinkParams
6194
): string {
6295
const params = new URLSearchParams()
6396
if (isInviteFlow) params.set('invite_flow', 'true')
6497
if (callbackUrl) params.set('callbackUrl', callbackUrl)
98+
if (isNewUser) params.set('new', 'true')
6599

66100
const query = params.toString()
67101
return query ? `${path}?${query}` : path

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,14 @@ export default function LoginPage({
8888
googleAvailable,
8989
microsoftAvailable,
9090
isProduction,
91+
registrationDisabled,
9192
}: {
9293
githubAvailable: boolean
9394
googleAvailable: boolean
9495
microsoftAvailable: boolean
9596
isProduction: boolean
97+
/** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */
98+
registrationDisabled: boolean
9699
}) {
97100
const router = useRouter()
98101
const searchParams = useSearchParams()
@@ -436,7 +439,7 @@ export default function LoginPage({
436439
</SocialLoginButtons>
437440
)}
438441

439-
{emailEnabled && (
442+
{emailEnabled && !registrationDisabled && (
440443
<AuthNavPrompt prompt="Don't have an account?" href={signupHref} linkLabel='Sign up' />
441444
)}
442445

apps/sim/app/(auth)/login/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Suspense } from 'react'
22
import type { Metadata } from 'next'
3+
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
34
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
5+
import LoginLoading from '@/app/(auth)/login/loading'
46
import LoginForm from '@/app/(auth)/login/login-form'
57

68
export const metadata: Metadata = {
@@ -14,12 +16,13 @@ export default async function LoginPage() {
1416
await getOAuthProviderStatus()
1517

1618
return (
17-
<Suspense fallback={null}>
19+
<Suspense fallback={<LoginLoading />}>
1820
<LoginForm
1921
githubAvailable={githubAvailable}
2022
googleAvailable={googleAvailable}
2123
microsoftAvailable={microsoftAvailable}
2224
isProduction={isProduction}
25+
registrationDisabled={isRegistrationDisabled}
2326
/>
2427
</Suspense>
2528
)

apps/sim/app/(auth)/signup/page.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { Metadata } from 'next'
2+
import type { SearchParams } from 'nuqs/server'
23
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
4+
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
35
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
6+
import { resolveAuthRedirect } from '@/app/(auth)/auth-redirect'
47
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
8+
import { RegistrationDisabled } from '@/app/(auth)/signup/registration-disabled'
9+
import { signupSearchParamsCache } from '@/app/(auth)/signup/search-params'
510
import SignupForm from '@/app/(auth)/signup/signup-form'
611

712
export const metadata: Metadata = {
@@ -10,9 +15,25 @@ export const metadata: Metadata = {
1015

1116
export const dynamic = 'force-dynamic'
1217

13-
export default async function SignupPage() {
18+
export default async function SignupPage({
19+
searchParams,
20+
}: {
21+
searchParams: Promise<SearchParams>
22+
}) {
1423
if (isRegistrationDisabled) {
15-
return <div>Registration is disabled, please contact your admin.</div>
24+
const { redirect, callbackUrl, inviteFlow } = await signupSearchParamsCache.parse(searchParams)
25+
const { rawCallbackUrl, isInviteFlow } = resolveAuthRedirect({
26+
redirect,
27+
callbackUrl,
28+
inviteFlow,
29+
})
30+
31+
return (
32+
<RegistrationDisabled
33+
callbackUrl={validateCallbackUrl(rawCallbackUrl) ? rawCallbackUrl : null}
34+
isInviteFlow={isInviteFlow}
35+
/>
36+
)
1637
}
1738

1839
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
2+
import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components'
3+
4+
interface RegistrationDisabledProps {
5+
/** Post-auth destination the visitor arrived with, already validated. */
6+
callbackUrl: string | null
7+
isInviteFlow: boolean
8+
}
9+
10+
/**
11+
* The signup page under DISABLE_REGISTRATION. Visitors reach it from a stale
12+
* link, a bookmark, or an invitation, so it wears the same shell as the form it
13+
* replaces and carries the post-auth destination over to login — an invited
14+
* visitor who lands here can still sign in and end up back on their invitation
15+
* rather than losing it.
16+
*/
17+
export function RegistrationDisabled({ callbackUrl, isInviteFlow }: RegistrationDisabledProps) {
18+
return (
19+
<div className='space-y-6'>
20+
<AuthHeader
21+
title='Account creation is disabled'
22+
description='Ask your admin to create an account for you.'
23+
/>
24+
<AuthNavPrompt
25+
prompt='Already have an account?'
26+
href={buildAuthCrossLink('/login', { callbackUrl, isInviteFlow })}
27+
linkLabel='Sign in'
28+
/>
29+
</div>
30+
)
31+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
2+
3+
/**
4+
* The redirect signals the signup page carries. Read once to decide where a
5+
* visitor goes after authenticating, never written, so every parser is nullable
6+
* with no default — absent means "no destination", which is a real state rather
7+
* than something to fall back from.
8+
*/
9+
const signupParsers = {
10+
redirect: parseAsString,
11+
callbackUrl: parseAsString,
12+
inviteFlow: parseAsString,
13+
} as const
14+
15+
/** `invite_flow` on the wire; camelCase for destructuring. */
16+
const signupUrlKeys = { urlKeys: { inviteFlow: 'invite_flow' } } as const
17+
18+
/**
19+
* Server-side reader for the signup page. The client form reads these same keys
20+
* through `useSearchParams` (the read-once auth-signal carve-out), so the wire
21+
* keys here and in `signup-form.tsx` must stay in step.
22+
*/
23+
export const signupSearchParamsCache = createSearchParamsCache(signupParsers, signupUrlKeys)

0 commit comments

Comments
 (0)