From 23bec77aa4f10cef1aa8bd8e63b4704a9f708110 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 20:08:52 -0700 Subject: [PATCH] fix(auth): stop offering account creation when registration is disabled DISABLE_REGISTRATION blocks /signup server-side, but the invite flow, the login form, the SSO form, and the CLI handoff all kept routing people there, stranding invited users on a dead end. The flag also never covered OAuth account creation, so social sign-in still minted accounts for unknown identities. --- .../platform/self-hosting/authentication.mdx | 6 +- .../self-hosting/environment-variables.mdx | 2 +- apps/sim/app/(auth)/auth-redirect.test.ts | 52 ++++++- apps/sim/app/(auth)/auth-redirect.ts | 36 ++++- apps/sim/app/(auth)/login/login-form.tsx | 5 +- apps/sim/app/(auth)/login/page.tsx | 2 + apps/sim/app/(auth)/signup/page.tsx | 25 ++- .../(auth)/signup/registration-disabled.tsx | 31 ++++ apps/sim/app/(auth)/signup/search-params.ts | 23 +++ apps/sim/app/(auth)/signup/signup-form.tsx | 13 +- apps/sim/app/(auth)/sso/page.tsx | 4 +- apps/sim/app/cli/auth/page.test.tsx | 71 +++++++++ apps/sim/app/cli/auth/page.tsx | 13 +- apps/sim/app/invite/[id]/invite.test.tsx | 75 +++++++-- apps/sim/app/invite/[id]/invite.tsx | 145 ++++++++++++------ apps/sim/app/invite/[id]/page.tsx | 3 +- apps/sim/app/oauth-error/page.tsx | 8 + apps/sim/ee/sso/components/sso-form.test.tsx | 25 ++- apps/sim/ee/sso/components/sso-form.tsx | 9 +- apps/sim/lib/auth/auth.ts | 66 ++++---- apps/sim/lib/auth/constants.test.ts | 50 ++++++ apps/sim/lib/auth/constants.ts | 54 +++++++ 22 files changed, 616 insertions(+), 102 deletions(-) create mode 100644 apps/sim/app/(auth)/signup/registration-disabled.tsx create mode 100644 apps/sim/app/(auth)/signup/search-params.ts create mode 100644 apps/sim/app/cli/auth/page.test.tsx diff --git a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx index 15bcd34c7f7..d997438d09b 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx @@ -84,7 +84,7 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th | Variable | Effect | |---|---| -| `DISABLE_REGISTRATION=true` | Blocks email/password registration | +| `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 | | `DISABLE_EMAIL_SIGNUP=true` | Blocks new email/password registrations; existing email login keeps working | | `ALLOWED_LOGIN_DOMAINS` | Comma-separated domain allowlist, e.g. `acme.com,acme.co.uk`. Gates email sign-**in** as well as signup | | `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 | `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above | - 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. + `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. + + `DISABLE_REGISTRATION` and `BLOCKED_SIGNUP_DOMAINS` apply to every path, social included. For a company deployment, the usual pairing is domain-restricted signup plus SSO: diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 8a2f01bf27f..3835925ca00 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -107,7 +107,7 @@ See [Authentication](/platform/self-hosting/authentication). | Variable | Description | |----------|-------------| -| `DISABLE_REGISTRATION` | Set `true` to disable new user signups entirely | +| `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 | | `DISABLE_EMAIL_SIGNUP` | Block new email/password registrations; existing email login keeps working | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts index e4b9e25b3df..93a94dd6eae 100644 --- a/apps/sim/app/(auth)/auth-redirect.test.ts +++ b/apps/sim/app/(auth)/auth-redirect.test.ts @@ -2,7 +2,11 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect' +import { + buildAuthCrossLink, + resolveAuthRedirect, + resolvePostSignupDestination, +} from '@/app/(auth)/auth-redirect' describe('resolvePostSignupDestination', () => { it('routes to the verify hop when verification is enforceable', () => { @@ -56,4 +60,50 @@ describe('buildAuthCrossLink', () => { '/signup' ) }) + + it('marks a new user so the invite page leads with account creation', () => { + expect( + buildAuthCrossLink('/signup', { + callbackUrl: '/invite/abc', + isInviteFlow: true, + isNewUser: true, + }) + ).toBe('/signup?invite_flow=true&callbackUrl=%2Finvite%2Fabc&new=true') + }) + + it('omits the new-user marker by default', () => { + expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: true })).not.toContain( + 'new=true' + ) + }) +}) + +describe('resolveAuthRedirect', () => { + const NONE = { redirect: null, callbackUrl: null, inviteFlow: null } + + it('prefers redirect over callbackUrl', () => { + expect(resolveAuthRedirect({ ...NONE, redirect: '/a', callbackUrl: '/b' }).rawCallbackUrl).toBe( + '/a' + ) + }) + + it('falls through an empty redirect to callbackUrl', () => { + expect( + resolveAuthRedirect({ ...NONE, redirect: '', callbackUrl: '/invite/abc' }).rawCallbackUrl + ).toBe('/invite/abc') + }) + + it('reports no destination when nothing was carried', () => { + expect(resolveAuthRedirect(NONE)).toEqual({ rawCallbackUrl: '', isInviteFlow: false }) + }) + + it('treats an invitation destination as an invite flow without the flag', () => { + expect(resolveAuthRedirect({ ...NONE, callbackUrl: '/invite/abc' }).isInviteFlow).toBe(true) + }) + + it('honors the explicit flag when the destination is unrelated', () => { + expect( + resolveAuthRedirect({ ...NONE, callbackUrl: '/workspace', inviteFlow: 'true' }).isInviteFlow + ).toBe(true) + }) }) diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts index 0cfd1310b22..269528c0bb1 100644 --- a/apps/sim/app/(auth)/auth-redirect.ts +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -43,10 +43,43 @@ export function resolvePostSignupDestination({ return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' } } +/** The raw redirect-carrying params, as read from a URL on client or server. */ +interface AuthRedirectParams { + redirect: string | null + callbackUrl: string | null + inviteFlow: string | null +} + +/** + * The post-auth destination a visitor arrived with, and whether they are mid + * invitation. + * + * `redirect` wins over `callbackUrl` — both spellings are in circulation. The + * invite flow is inferred from the destination as well as the explicit flag, so + * a link that lost `invite_flow` still reads as an invitation. + * + * Shared so the signup form and the registration-disabled page cannot drift on + * which param wins; both feed the result to {@link buildAuthCrossLink}. The + * caller validates — this function does not, so that a client can log the + * rejection it already reports. + */ +export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthRedirectParams): { + rawCallbackUrl: string + isInviteFlow: boolean +} { + const rawCallbackUrl = redirect || callbackUrl || '' + return { + rawCallbackUrl, + isInviteFlow: inviteFlow === 'true' || rawCallbackUrl.startsWith('/invite/'), + } +} + interface AuthCrossLinkParams { /** Validated post-auth destination to carry over, or null to drop it. */ callbackUrl: string | null isInviteFlow: boolean + /** Marks the visitor as new so the invite page leads with account creation. */ + isNewUser?: boolean } /** @@ -57,11 +90,12 @@ interface AuthCrossLinkParams { */ export function buildAuthCrossLink( path: '/login' | '/signup', - { callbackUrl, isInviteFlow }: AuthCrossLinkParams + { callbackUrl, isInviteFlow, isNewUser = false }: AuthCrossLinkParams ): string { const params = new URLSearchParams() if (isInviteFlow) params.set('invite_flow', 'true') if (callbackUrl) params.set('callbackUrl', callbackUrl) + if (isNewUser) params.set('new', 'true') const query = params.toString() return query ? `${path}?${query}` : path diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index a2349c428eb..380aeb89a42 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -88,11 +88,14 @@ export default function LoginPage({ googleAvailable, microsoftAvailable, isProduction, + registrationDisabled, }: { githubAvailable: boolean googleAvailable: boolean microsoftAvailable: boolean isProduction: boolean + /** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */ + registrationDisabled: boolean }) { const router = useRouter() const searchParams = useSearchParams() @@ -436,7 +439,7 @@ export default function LoginPage({ )} - {emailEnabled && ( + {emailEnabled && !registrationDisabled && ( )} diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx index ecec5ceb41a..1490ac85e44 100644 --- a/apps/sim/app/(auth)/login/page.tsx +++ b/apps/sim/app/(auth)/login/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import LoginForm from '@/app/(auth)/login/login-form' @@ -20,6 +21,7 @@ export default async function LoginPage() { googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} isProduction={isProduction} + registrationDisabled={isRegistrationDisabled} /> ) diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 3d5a8933cd6..d43dc7c0475 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,7 +1,12 @@ import type { Metadata } from 'next' +import type { SearchParams } from 'nuqs/server' import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' +import { resolveAuthRedirect } from '@/app/(auth)/auth-redirect' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' +import { RegistrationDisabled } from '@/app/(auth)/signup/registration-disabled' +import { signupSearchParamsCache } from '@/app/(auth)/signup/search-params' import SignupForm from '@/app/(auth)/signup/signup-form' export const metadata: Metadata = { @@ -10,9 +15,25 @@ export const metadata: Metadata = { export const dynamic = 'force-dynamic' -export default async function SignupPage() { +export default async function SignupPage({ + searchParams, +}: { + searchParams: Promise +}) { if (isRegistrationDisabled) { - return
Registration is disabled, please contact your admin.
+ const { redirect, callbackUrl, inviteFlow } = await signupSearchParamsCache.parse(searchParams) + const { rawCallbackUrl, isInviteFlow } = resolveAuthRedirect({ + redirect, + callbackUrl, + inviteFlow, + }) + + return ( + + ) } const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } = diff --git a/apps/sim/app/(auth)/signup/registration-disabled.tsx b/apps/sim/app/(auth)/signup/registration-disabled.tsx new file mode 100644 index 00000000000..2096c056506 --- /dev/null +++ b/apps/sim/app/(auth)/signup/registration-disabled.tsx @@ -0,0 +1,31 @@ +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components' + +interface RegistrationDisabledProps { + /** Post-auth destination the visitor arrived with, already validated. */ + callbackUrl: string | null + isInviteFlow: boolean +} + +/** + * The signup page under DISABLE_REGISTRATION. Visitors reach it from a stale + * link, a bookmark, or an invitation, so it wears the same shell as the form it + * replaces and carries the post-auth destination over to login — an invited + * visitor who lands here can still sign in and end up back on their invitation + * rather than losing it. + */ +export function RegistrationDisabled({ callbackUrl, isInviteFlow }: RegistrationDisabledProps) { + return ( +
+ + +
+ ) +} diff --git a/apps/sim/app/(auth)/signup/search-params.ts b/apps/sim/app/(auth)/signup/search-params.ts new file mode 100644 index 00000000000..54c247dc2a9 --- /dev/null +++ b/apps/sim/app/(auth)/signup/search-params.ts @@ -0,0 +1,23 @@ +import { createSearchParamsCache, parseAsString } from 'nuqs/server' + +/** + * The redirect signals the signup page carries. Read once to decide where a + * visitor goes after authenticating, never written, so every parser is nullable + * with no default — absent means "no destination", which is a real state rather + * than something to fall back from. + */ +const signupParsers = { + redirect: parseAsString, + callbackUrl: parseAsString, + inviteFlow: parseAsString, +} as const + +/** `invite_flow` on the wire; camelCase for destructuring. */ +const signupUrlKeys = { urlKeys: { inviteFlow: 'invite_flow' } } as const + +/** + * Server-side reader for the signup page. The client form reads these same keys + * through `useSearchParams` (the read-once auth-signal carve-out), so the wire + * keys here and in `signup-form.tsx` must stay in step. + */ +export const signupSearchParamsCache = createSearchParamsCache(signupParsers, signupUrlKeys) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 4915da788b3..ad0d5213b97 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { Suspense, useEffect, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' @@ -15,6 +15,7 @@ import { buildAuthCrossLink, DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY, + resolveAuthRedirect, resolvePostSignupDestination, VERIFY_FROM_SIGNUP_ROUTE, } from '@/app/(auth)/auth-redirect' @@ -123,7 +124,11 @@ function SignupFormContent({ const [formError, setFormError] = useState(null) const turnstileRef = useRef(null) const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) - const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || '' + const { rawCallbackUrl: rawRedirectUrl, isInviteFlow } = resolveAuthRedirect({ + redirect: searchParams.get('redirect'), + callbackUrl: searchParams.get('callbackUrl'), + inviteFlow: searchParams.get('invite_flow'), + }) const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false const invalidCallbackRef = useRef(false) if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) { @@ -131,10 +136,6 @@ function SignupFormContent({ logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl }) } const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : '' - const isInviteFlow = useMemo( - () => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'), - [searchParams, redirectUrl] - ) const [name, setName] = useState('') const [nameErrors, setNameErrors] = useState([]) diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx index b1e27f22608..14463bf4abb 100644 --- a/apps/sim/app/(auth)/sso/page.tsx +++ b/apps/sim/app/(auth)/sso/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { redirect } from 'next/navigation' -import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags' import SSOForm from '@/ee/sso/components/sso-form' export const metadata: Metadata = { @@ -17,7 +17,7 @@ export default async function SSOPage() { return ( - + ) } diff --git a/apps/sim/app/cli/auth/page.test.tsx b/apps/sim/app/cli/auth/page.test.tsx new file mode 100644 index 00000000000..ce1cbcd7d86 --- /dev/null +++ b/apps/sim/app/cli/auth/page.test.tsx @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { envFlagsMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockRedirect } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockRedirect: vi.fn((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`) + }), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('next/navigation', () => ({ + redirect: mockRedirect, +})) + +import CliAuthPage from '@/app/cli/auth/page' + +/** BASE64URL, 43 chars; pairing is `XXXX-XXXX` over the no-look-alike alphabet. */ +const REQUEST = 'r'.repeat(43) +const CHALLENGE = 'c'.repeat(43) +const PAIRING = 'ABCD-2345' + +const EXPECTED_CALLBACK = encodeURIComponent( + `/cli/auth?request=${REQUEST}&challenge=${CHALLENGE}&pairing=${PAIRING}` +) + +function pageProps() { + return { + searchParams: Promise.resolve({ + request: REQUEST, + challenge: CHALLENGE, + pairing: PAIRING, + }), + } +} + +describe('CliAuthPage signed-out bounce', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + }) + + afterEach(() => { + envFlagsMock.isRegistrationDisabled = false + }) + + it('sends a signed-out visitor to signup, carrying the handoff as callbackUrl', async () => { + await expect(CliAuthPage(pageProps())).rejects.toThrow( + `NEXT_REDIRECT:/signup?callbackUrl=${EXPECTED_CALLBACK}` + ) + }) + + /** + * Nobody can create an account under the flag, so the pairing visitor is + * necessarily an existing user and signup would be a guaranteed dead end. + */ + it('sends them to login instead when registration is disabled', async () => { + envFlagsMock.isRegistrationDisabled = true + + await expect(CliAuthPage(pageProps())).rejects.toThrow( + `NEXT_REDIRECT:/login?callbackUrl=${EXPECTED_CALLBACK}` + ) + }) +}) diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index 2bc0d86370a..e49a47be4cc 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -3,6 +3,8 @@ import type { Metadata } from 'next' import { redirect } from 'next/navigation' import type { SearchParams } from 'nuqs/server' import { getSession } from '@/lib/auth' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { AuthShell } from '@/app/(auth)/components' import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { CliAuthView } from '@/app/cli/auth/cli-auth-view' @@ -30,6 +32,10 @@ export const dynamic = 'force-dynamic' * configuring Sim for the first time has no account yet. Both auth pages * cross-link carrying the same `callbackUrl`, so a returning user is one click * from login with their destination intact. + * + * That reasoning inverts under DISABLE_REGISTRATION: nobody can create an + * account, so the pairing visitor is necessarily an existing user and signup is + * guaranteed to be the wrong hop. Go straight to login there. */ export default async function CliAuthPage({ searchParams, @@ -49,7 +55,12 @@ export default async function CliAuthPage({ challenge: resolution.request.challenge, pairing: resolution.request.pairing, }) - redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) + redirect( + buildAuthCrossLink(isRegistrationDisabled ? '/login' : '/signup', { + callbackUrl: `/cli/auth?${query}`, + isInviteFlow: false, + }) + ) } return ( diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx index 83fba300438..24363971dc4 100644 --- a/apps/sim/app/invite/[id]/invite.test.tsx +++ b/apps/sim/app/invite/[id]/invite.test.tsx @@ -27,9 +27,7 @@ const { }, mockPush: vi.fn(), mockRequestJson: vi.fn(), - mockSearchParams: { - get: (key: string) => (key === 'token' ? 'token-1' : null), - }, + mockSearchParams: { current: new URLSearchParams('token=token-1') }, mockSetActive: vi.fn(), mockSetQueryData: vi.fn(), mockSignOut: vi.fn(), @@ -43,7 +41,7 @@ vi.mock('@sim/logger', () => ({ vi.mock('next/navigation', () => ({ useParams: () => ({ id: 'invitation-1' }), useRouter: () => ({ push: mockPush }), - useSearchParams: () => mockSearchParams, + useSearchParams: () => mockSearchParams.current, })) vi.mock('@tanstack/react-query', async () => { @@ -106,15 +104,18 @@ vi.mock('@/app/invite/components', () => ({ InviteLayout: ({ children }: { children: ReactNode }) => children, InviteStatusCard: ({ actions = [], + description, title, type, }: { actions?: Array<{ label: string; onClick: () => void }> + description?: ReactNode title: string type: string }) => ( <>
{title}
+
{description}
{actions.map((action) => (