From 5b4ecb4038928b3d1273c7f6d82ace15a9ccf56a Mon Sep 17 00:00:00 2001 From: Maria Mantsurova Date: Wed, 8 Jul 2026 17:49:37 +0300 Subject: [PATCH 1/5] feat(create): add Descope auth add-on for React --- .changeset/descope-auth-addon.md | 5 + .../react/add-ons/descope/README.md | 45 ++++++++ .../descope/assets/_dot_env.local.append | 2 + .../src/integrations/descope/header-user.tsx | 33 ++++++ .../src/integrations/descope/provider.tsx | 14 +++ .../assets/src/routes/demo/descope.tsx | 107 ++++++++++++++++++ .../react/add-ons/descope/info.json | 42 +++++++ .../frameworks/react/add-ons/descope/logo.svg | 34 ++++++ .../react/add-ons/descope/package.json | 5 + .../react/add-ons/descope/small-logo.svg | 28 +++++ 10 files changed, 315 insertions(+) create mode 100644 .changeset/descope-auth-addon.md create mode 100644 packages/create/src/frameworks/react/add-ons/descope/README.md create mode 100644 packages/create/src/frameworks/react/add-ons/descope/assets/_dot_env.local.append create mode 100644 packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx create mode 100644 packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/provider.tsx create mode 100644 packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx create mode 100644 packages/create/src/frameworks/react/add-ons/descope/info.json create mode 100644 packages/create/src/frameworks/react/add-ons/descope/logo.svg create mode 100644 packages/create/src/frameworks/react/add-ons/descope/package.json create mode 100644 packages/create/src/frameworks/react/add-ons/descope/small-logo.svg diff --git a/.changeset/descope-auth-addon.md b/.changeset/descope-auth-addon.md new file mode 100644 index 000000000..6efa5e12c --- /dev/null +++ b/.changeset/descope-auth-addon.md @@ -0,0 +1,5 @@ +--- +"@tanstack/create": minor +--- + +Add Descope authentication add-on for React. Wires `` at the app root via `VITE_DESCOPE_PROJECT_ID`, a header user menu, and a `/demo/descope` route rendering the `sign-up-or-in` flow. Mutually exclusive with other auth providers. diff --git a/packages/create/src/frameworks/react/add-ons/descope/README.md b/packages/create/src/frameworks/react/add-ons/descope/README.md new file mode 100644 index 000000000..26cb3cd81 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/README.md @@ -0,0 +1,45 @@ +## Setting up Descope + +1. Sign up at [descope.com](https://descope.com) and create a project +2. Copy your **Project ID** from the [Console](https://app.descope.com/settings/project) +3. Set it in your `.env.local`: + ```bash + VITE_DESCOPE_PROJECT_ID=P... + ``` +4. Make sure a **`sign-up-or-in`** flow exists in the Console (it ships by default) +5. Add `http://localhost:3000` to your project's approved domains / redirect URLs +6. Visit the demo route at `/demo/descope` once `npm run dev` is running + +### What's wired up + +- **``** at the app root (`src/integrations/descope/provider.tsx`) supplies auth context to the whole tree +- **Header user menu** (`src/integrations/descope/header-user.tsx`) swaps between a "Sign in" link and the signed-in avatar + "Sign out" +- **`/demo/descope`** renders the Descope `` flow and a signed-in greeting + +### Protecting a route + +Use the `useSession` hook to gate content: + +```tsx +import { useSession } from '@descope/react-sdk' + +function ProtectedPage() { + const { isAuthenticated, isSessionLoading } = useSession() + + if (isSessionLoading) return

Loading…

+ if (!isAuthenticated) return + + return +} +``` + +For server-side checks (route loaders, server functions), validate the session +token with the [`@descope/node-sdk`](https://docs.descope.com/sdks/nodejs) and +`validateSession()`. + +### Production checklist + +- Use a dedicated **production project** and its Project ID +- Configure your production domain under **Project Settings → Domains** +- Customize your flows (branding, connectors, MFA) in the **Flows** section of the Console +- Enable the social / SSO connectors you need under **Authentication Methods** diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/_dot_env.local.append b/packages/create/src/frameworks/react/add-ons/descope/assets/_dot_env.local.append new file mode 100644 index 000000000..4a89c2a83 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/_dot_env.local.append @@ -0,0 +1,2 @@ +# Descope configuration, get your Project ID from the [Console](https://app.descope.com/settings/project) +VITE_DESCOPE_PROJECT_ID= diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx new file mode 100644 index 000000000..f5ff5a6cc --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx @@ -0,0 +1,33 @@ +import { useDescope, useSession, useUser } from '@descope/react-sdk' + +export default function HeaderUser() { + const { isAuthenticated, isSessionLoading } = useSession() + const { user } = useUser() + const sdk = useDescope() + + if (isSessionLoading || !isAuthenticated) return null + + const email = user?.email + const initial = (user?.name || email || 'U').charAt(0).toUpperCase() + + return ( +
+ {user?.picture ? ( + + ) : ( +
+ + {initial} + +
+ )} + +
+ ) +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/provider.tsx b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/provider.tsx new file mode 100644 index 000000000..b4d31dcb6 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/provider.tsx @@ -0,0 +1,14 @@ +import { AuthProvider } from '@descope/react-sdk' + +const PROJECT_ID = import.meta.env.VITE_DESCOPE_PROJECT_ID +if (!PROJECT_ID) { + throw new Error('Add your Descope Project ID to the .env.local file') +} + +export default function AppDescopeProvider({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx b/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx new file mode 100644 index 000000000..466546db9 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx @@ -0,0 +1,107 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Descope, useSession, useUser } from '@descope/react-sdk' + +export const Route = createFileRoute('/demo/descope')({ + component: DescopeDemo, +}) + +function DescopeDemo() { + const { isAuthenticated, isSessionLoading } = useSession() + + return ( +
+
+ {isSessionLoading ? ( +

Loading…

+ ) : isAuthenticated ? ( + + ) : ( + + )} +
+
+ ) +} + +function SignedOut() { + return ( + <> +
+

Descope

+

Sign in to continue

+

+ Descope renders the sign-in flow, manages sessions, and handles + passwordless, SSO, and social login for you. +

+
+
+ console.error('Descope flow error', err)} + /> +
+

+ Built with{' '} + + DESCOPE + + . +

+ + ) +} + +function SignedInGreeting() { + const { user } = useUser() + if (!user) return null + + const email = user.email + const initial = (user.name || email || 'U').charAt(0).toUpperCase() + + return ( +
+
+

Descope

+

Welcome back

+

You're signed in as {email}

+
+ +
+ {user.picture ? ( + + ) : ( +
+ + {initial} + +
+ )} +
+

{user.name}

+

+ {email} +

+
+
+ +

+ Sign out from the avatar in the header. Built with{' '} + + DESCOPE + + . +

+
+ ) +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/info.json b/packages/create/src/frameworks/react/add-ons/descope/info.json new file mode 100644 index 000000000..66302e867 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/info.json @@ -0,0 +1,42 @@ +{ + "name": "Descope", + "description": "Drop-in auth with hosted flows, passwordless, SSO, and social login (managed).", + "phase": "add-on", + "modes": ["file-router"], + "type": "add-on", + "category": "auth", + "exclusive": ["auth"], + "color": "#0081B3", + "priority": 170, + "link": "https://descope.com", + "routes": [ + { + "icon": "CircleUserRound", + "url": "/demo/descope", + "name": "Descope", + "path": "src/routes/demo.descope.tsx", + "jsName": "DescopeDemo" + } + ], + "integrations": [ + { + "type": "header-user", + "jsName": "DescopeHeader", + "path": "src/integrations/descope/header-user.tsx" + }, + { + "type": "provider", + "jsName": "DescopeProvider", + "path": "src/integrations/descope/provider.tsx" + } + ], + "envVars": [ + { + "name": "VITE_DESCOPE_PROJECT_ID", + "description": "Descope project ID", + "required": true, + "secret": false, + "file": ".env.local" + } + ] +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/logo.svg b/packages/create/src/frameworks/react/add-ons/descope/logo.svg new file mode 100644 index 000000000..1c1539e1c --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/logo.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/create/src/frameworks/react/add-ons/descope/package.json b/packages/create/src/frameworks/react/add-ons/descope/package.json new file mode 100644 index 000000000..4ed939022 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@descope/react-sdk": "^2.30.3" + } +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/small-logo.svg b/packages/create/src/frameworks/react/add-ons/descope/small-logo.svg new file mode 100644 index 000000000..96717294d --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/small-logo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + From ddc1ec6064efb4d88a4d95dab706d787be5236ef Mon Sep 17 00:00:00 2001 From: Maria Mantsurova Date: Thu, 9 Jul 2026 10:49:41 +0300 Subject: [PATCH 2/5] fix(create): address review feedback on Descope add-on - Gate header avatar on isUserLoading to avoid initial fallback flash - Handle sdk.logout() promise rejection - Fix README to match header-user behavior (no sign-in link) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../create/src/frameworks/react/add-ons/descope/README.md | 2 +- .../assets/src/integrations/descope/header-user.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/create/src/frameworks/react/add-ons/descope/README.md b/packages/create/src/frameworks/react/add-ons/descope/README.md index 26cb3cd81..4c6d6c74c 100644 --- a/packages/create/src/frameworks/react/add-ons/descope/README.md +++ b/packages/create/src/frameworks/react/add-ons/descope/README.md @@ -13,7 +13,7 @@ ### What's wired up - **``** at the app root (`src/integrations/descope/provider.tsx`) supplies auth context to the whole tree -- **Header user menu** (`src/integrations/descope/header-user.tsx`) swaps between a "Sign in" link and the signed-in avatar + "Sign out" +- **Header user menu** (`src/integrations/descope/header-user.tsx`) renders the signed-in avatar + "Sign out" button (hidden when signed out) - **`/demo/descope`** renders the Descope `` flow and a signed-in greeting ### Protecting a route diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx index f5ff5a6cc..e8ef82a42 100644 --- a/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx @@ -2,10 +2,10 @@ import { useDescope, useSession, useUser } from '@descope/react-sdk' export default function HeaderUser() { const { isAuthenticated, isSessionLoading } = useSession() - const { user } = useUser() + const { user, isUserLoading } = useUser() const sdk = useDescope() - if (isSessionLoading || !isAuthenticated) return null + if (isSessionLoading || isUserLoading || !isAuthenticated) return null const email = user?.email const initial = (user?.name || email || 'U').charAt(0).toUpperCase() @@ -23,7 +23,9 @@ export default function HeaderUser() { )} -
- console.error('Descope flow error', err)} - /> -
-

- Built with{' '} - - DESCOPE - - . -

- + ) + } + + return ( +
+ {status === 'loading' &&

Loading sign-in…

} + { + ready.current = true + setStatus('ready') + }} + onError={() => setStatus('error')} + /> +
) } @@ -67,9 +97,7 @@ function SignedInGreeting() { return (
-

Descope

-

Welcome back

-

You're signed in as {email}

+

Signed in

@@ -89,19 +117,6 @@ function SignedInGreeting() {

- -

- Sign out from the avatar in the header. Built with{' '} - - DESCOPE - - . -

) } From 28c5d404d7be121dd6ad644d0bfd13f2ac06029f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 6 Nov 2026 10:47:01 -0700 Subject: [PATCH 4/5] fix(create): preserve Descope fallback and polling recovery --- packages/cli/tests-e2e/descope-smoke.spec.ts | 141 ++++++++++++++---- .../assets/src/routes/demo/descope.tsx | 33 ++-- 2 files changed, 127 insertions(+), 47 deletions(-) diff --git a/packages/cli/tests-e2e/descope-smoke.spec.ts b/packages/cli/tests-e2e/descope-smoke.spec.ts index 72d8b7434..ef4dbe3da 100644 --- a/packages/cli/tests-e2e/descope-smoke.spec.ts +++ b/packages/cli/tests-e2e/descope-smoke.spec.ts @@ -3,7 +3,7 @@ import fs from 'node:fs' import path from 'node:path' import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' -import { test } from '@playwright/test' +import { expect, test } from '@playwright/test' import type { BrowserContext } from '@playwright/test' import { createReactAppFixture } from './helpers' @@ -31,11 +31,20 @@ test('@blocking handles Descope initialization failure, retry, and session event ) assets = path.resolve(dir, '..', 'descope-ui-fixture') fs.mkdirSync(assets) - const response = await fetch( - 'https://registry.npmjs.org/@descope/web-components-ui/-/web-components-ui-3.27.2.tgz', - ) - assert.ok(response.ok) - const archive = Buffer.from(await response.arrayBuffer()) + let archive: Buffer + try { + const response = await fetch( + 'https://registry.npmjs.org/@descope/web-components-ui/-/web-components-ui-3.27.2.tgz', + { signal: AbortSignal.timeout(30_000) }, + ) + assert.ok(response.ok, `Registry returned ${response.status}`) + archive = Buffer.from(await response.arrayBuffer()) + } catch (cause) { + throw new Error( + 'Could not download the pinned Descope UI within 30 seconds', + { cause }, + ) + } assert.equal( 'sha512-' + createHash('sha512').update(archive).digest('base64'), UI_INTEGRITY, @@ -57,10 +66,17 @@ test('@blocking handles Descope initialization failure, retry, and session event assets = path.join(assets, 'package/dist/umd') }, }) - context = await browser.newContext() + context = await browser.newContext({ locale: 'de' }) const page = await context.newPage() const errors: Array = [] page.on('pageerror', (e) => errors.push(e.message)) + let polling = false + let htmlRequests = 0 + let pollRequests = 0 + let releasePoll!: () => void + const pollRecovery = new Promise((resolve) => { + releasePoll = resolve + }) let healthy = false, configRequests = 0, logoutRequests = 0 @@ -79,7 +95,9 @@ test('@blocking handles Descope initialization failure, retry, and session event return healthy ? route.fulfill({ json: { - flows: { 'sign-up-or-in': { version: 1 } }, + flows: { + 'sign-up-or-in': { version: 1, targetLocales: ['de'] }, + }, componentsVersion: '3.27.2', }, }) @@ -101,11 +119,50 @@ test('@blocking handles Descope initialization failure, retry, and session event if (fs.existsSync(file)) return route.fulfill({ path: file, contentType: 'text/javascript' }) } - if (url.pathname.endsWith('.html')) + if (url.pathname.endsWith('-de.html')) { + return route.fulfill({ status: 404, body: 'Localized page missing' }) + } + if (url.pathname.endsWith('.html')) { + htmlRequests++ return route.fulfill({ - body: '
Local sign-in fixture
', + body: url.pathname.includes('recovered-screen') + ? '
Recovered sign-in fixture
' + : '
Local sign-in fixture
' + + (polling && htmlRequests === 1 + ? '
' + : ''), contentType: 'text/html', }) + } + if (url.pathname.endsWith('/next')) { + pollRequests++ + if (pollRequests === 1) + return route.fulfill({ + json: { status: 'waiting', action: 'poll' }, + }) + if (pollRequests === 2) + return route.fulfill({ + status: 400, + json: { + errorCode: 'LOCAL_TRANSIENT', + errorDescription: 'Temporary local test failure', + }, + }) + await pollRecovery + return route.fulfill({ + json: { + executionId: 'local-execution', + stepId: '5', + status: 'waiting', + action: '', + screen: { + id: 'recovered-screen', + state: { project: { name: 'Local test' } }, + }, + stepName: 'Recovered', + }, + }) + } if (url.pathname.endsWith('/start')) return route.fulfill({ json: { @@ -133,16 +190,40 @@ test('@blocking handles Descope initialization failure, retry, and session event waitUntil: 'domcontentloaded', }) await page - .getByRole('button', { name: 'Try again' }) + .getByRole('button', { name: 'Restart sign-in' }) .waitFor({ timeout: 15000 }) assert.ok(configRequests > 0) const failedRequests = configRequests healthy = true - await page.getByRole('button', { name: 'Try again' }).click() + await page.getByRole('button', { name: 'Restart sign-in' }).click() await page .getByText('Local sign-in fixture', { exact: true }) .waitFor({ timeout: 20000 }) assert.ok(configRequests > failedRequests) + await expect(page.getByRole('alert')).toHaveCount(0) + + polling = true + htmlRequests = 0 + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.getByText('Local sign-in fixture', { exact: true }).waitFor() + const flow = await page.locator('descope-wc').elementHandle() + assert.ok(flow) + await page.getByRole('alert').waitFor() + assert.ok( + await flow.evaluate((element) => element.isConnected), + 'Polling error removed the flow', + ) + await page.getByRole('textbox', { name: 'Fixture input' }).fill('keep-me') + assert.equal( + await page.getByRole('textbox', { name: 'Fixture input' }).inputValue(), + 'keep-me', + ) + releasePoll() + await page.getByText('Recovered sign-in fixture', { exact: true }).waitFor() + assert.ok(pollRequests >= 3) + assert.ok(await flow.evaluate((element) => element.isConnected)) + await expect(page.getByRole('alert')).toHaveCount(0) + assert.equal( await page .getByRole('status') @@ -167,26 +248,24 @@ test('@blocking handles Descope initialization failure, retry, and session event ), ) .join('.') - await page - .locator('descope-wc') - .evaluate( - (element, jwt) => - element.dispatchEvent( - new CustomEvent('success', { - detail: { - sessionJwt: jwt, - refreshJwt: jwt, - user: { - userId: 'local-user', - name: 'Local User', - email: 'local@example.test', - loginIds: ['local@example.test'], - }, + await page.locator('descope-wc').evaluate( + (element, jwt) => + element.dispatchEvent( + new CustomEvent('success', { + detail: { + sessionJwt: jwt, + refreshJwt: jwt, + user: { + userId: 'local-user', + name: 'Local User', + email: 'local@example.test', + loginIds: ['local@example.test'], }, - }), - ), - jwt, - ) + }, + }), + ), + jwt, + ) await page .getByRole('button', { name: 'Sign out', exact: true }) .waitFor({ timeout: 10000 }) diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx b/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx index 7729872f4..15666b754 100644 --- a/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx @@ -48,28 +48,14 @@ function SignInFlow({ onRetry }: { onRetry: () => void }) { warn: console.warn, error: (...messages: unknown[]) => { console.error(...messages) - // The SDK reports initialization failures through its logger, before - // the flow is ready to emit authentication error events. + // Logging can also precede the SDK's own fallback or retry. Report the + // problem without unmounting the flow and interrupting that recovery. if (!ready.current) setStatus('error') }, }), [], ) - if (status === 'error') { - return ( -
-

- Sign-in could not load. Check your connection and Descope project - configuration. -

- -
- ) - } - return (
{status === 'loading' &&

Loading sign-in…

} @@ -82,7 +68,22 @@ function SignInFlow({ onRetry }: { onRetry: () => void }) { setStatus('ready') }} onError={() => setStatus('error')} + onScreenUpdate={() => { + if (ready.current) setStatus('ready') + return false + }} /> + {status === 'error' && ( +
+

+ Sign-in reported a problem. Check your connection, or restart this + flow. +

+ +
+ )}
) } From 170f22daf0383bbee648c0f49f81f062f9d7ee31 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 6 Nov 2026 11:22:29 -0700 Subject: [PATCH 5/5] test(cli): preserve Descope registry HTTP diagnostics --- packages/cli/tests-e2e/descope-smoke.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests-e2e/descope-smoke.spec.ts b/packages/cli/tests-e2e/descope-smoke.spec.ts index ef4dbe3da..ddf3a50a7 100644 --- a/packages/cli/tests-e2e/descope-smoke.spec.ts +++ b/packages/cli/tests-e2e/descope-smoke.spec.ts @@ -32,12 +32,12 @@ test('@blocking handles Descope initialization failure, retry, and session event assets = path.resolve(dir, '..', 'descope-ui-fixture') fs.mkdirSync(assets) let archive: Buffer + let response: Response try { - const response = await fetch( + response = await fetch( 'https://registry.npmjs.org/@descope/web-components-ui/-/web-components-ui-3.27.2.tgz', { signal: AbortSignal.timeout(30_000) }, ) - assert.ok(response.ok, `Registry returned ${response.status}`) archive = Buffer.from(await response.arrayBuffer()) } catch (cause) { throw new Error( @@ -45,6 +45,7 @@ test('@blocking handles Descope initialization failure, retry, and session event { cause }, ) } + assert.ok(response.ok, `Registry returned ${response.status}`) assert.equal( 'sha512-' + createHash('sha512').update(archive).digest('base64'), UI_INTEGRITY,