From 5b664c7f0f382d62184056728b61fd23c0d5ea61 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sun, 6 Sep 2026 11:03:14 +0530 Subject: [PATCH] fix(nextjs): resolve the hosted sign-up page for the redirect-based sign-up SignUpButton and useAsgardeo().signUp() did nothing unless a custom signUpUrl was configured: signUpAction returned an empty URL and the client threw "Not implemented" for a non-embedded sign-up. - AsgardeoNextClient.getSignUpUrl() returns the configured signUpUrl, or the identity server's self-registration page derived from baseUrl, clientId and applicationId (getRedirectBasedSignUpUrl), as the React SDK does. - signUpAction hands that URL to the browser, which navigates there, and reports an error when no URL can be resolved instead of silently doing nothing. Co-Authored-By: Claude Fable 5.1 --- .changeset/nextjs-redirect-signup.md | 5 + packages/nextjs/src/AsgardeoNextClient.ts | 21 +++- .../AsgardeoNextClient.signUp.test.ts | 90 ++++++++++++++ .../actions/__tests__/signUpAction.test.ts | 111 ++++++++++++++++++ .../nextjs/src/server/actions/signUpAction.ts | 28 +++-- 5 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 .changeset/nextjs-redirect-signup.md create mode 100644 packages/nextjs/src/__tests__/AsgardeoNextClient.signUp.test.ts create mode 100644 packages/nextjs/src/server/actions/__tests__/signUpAction.test.ts diff --git a/.changeset/nextjs-redirect-signup.md b/.changeset/nextjs-redirect-signup.md new file mode 100644 index 000000000..053e57ae0 --- /dev/null +++ b/.changeset/nextjs-redirect-signup.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +Redirect-based sign-up works. `SignUpButton` (and `useAsgardeo().signUp()`) did nothing unless a custom `signUpUrl` was configured, because the server action returned an empty URL and the client threw "Not implemented" for a non-embedded sign-up. The action now resolves the configured `signUpUrl`, or the identity server's self-registration page derived from `baseUrl`, `clientId` and `applicationId` as the React SDK does, and the browser navigates there. When neither can be resolved (for example a custom domain without `signUpUrl`) the action reports an error instead of silently doing nothing. diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 3d4426a3c..c609db4c6 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -21,6 +21,7 @@ import { AsgardeoNodeClient, AsgardeoRuntimeError, AuthClientConfig, + Config, CreateOrganizationPayload, EmbeddedFlowExecuteRequestConfig, EmbeddedFlowExecuteRequestPayload, @@ -52,6 +53,7 @@ import { getAllOrganizations, getMeOrganizations, getOrganization, + getRedirectBasedSignUpUrl, getScim2Me, getSchemas, initializeEmbeddedSignInFlow, @@ -573,13 +575,28 @@ class AsgardeoNextClient exte }); } throw new AsgardeoRuntimeError( - 'Not implemented', + 'The Next.js client cannot navigate to the hosted sign-up page; resolve it with `getSignUpUrl()` instead.', 'AsgardeoNextClient-ValidationError-002', 'nextjs', - 'The signUp method with SignUpOptions is not implemented in the Next.js client.', + 'The Next.js client runs on the server. Resolve the sign-up page with `getSignUpUrl()` and navigate from the browser (`useAsgardeo().signUp()` does this).', ); } + /** + * Gets the URL of the redirect-based sign-up page: the configured `signUpUrl`, or the identity server's + * self-registration page derived from `baseUrl`, `clientId` and `applicationId`, as in the React SDK. + * + * @returns The sign-up URL, or an empty string when none can be resolved (for example a custom domain + * without a configured `signUpUrl`). + */ + public async getSignUpUrl(): Promise { + await this.ensureInitialized(); + + const configData: AuthClientConfig = await this.asgardeo.getConfigData(); + + return configData?.signUpUrl || getRedirectBasedSignUpUrl(configData as unknown as Config); + } + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars override signInSilently(_options?: SignInOptions): Promise { throw new AsgardeoRuntimeError( diff --git a/packages/nextjs/src/__tests__/AsgardeoNextClient.signUp.test.ts b/packages/nextjs/src/__tests__/AsgardeoNextClient.signUp.test.ts new file mode 100644 index 000000000..954b653ff --- /dev/null +++ b/packages/nextjs/src/__tests__/AsgardeoNextClient.signUp.test.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {beforeAll, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import AsgardeoNextClient from '../AsgardeoNextClient'; + +const {legacyClient} = vi.hoisted(() => { + const hoistedLegacyClient: {getConfigData: Mock; initialize: Mock} = { + getConfigData: vi.fn(), + initialize: vi.fn(), + }; + + return {legacyClient: hoistedLegacyClient}; +}); + +vi.mock('@asgardeo/node', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + // The SDK instantiates the legacy client with `new`, which an arrow function cannot serve. + // eslint-disable-next-line prefer-arrow-callback + LegacyAsgardeoNodeClient: vi.fn(function LegacyAsgardeoNodeClientMock(): unknown { + return legacyClient; + }), +})); + +vi.mock('../server/actions/getClientOrigin', () => ({default: vi.fn(async () => 'http://localhost:3000')})); +vi.mock('../server/actions/getSessionId', () => ({default: vi.fn(async () => 'session-1')})); + +describe('AsgardeoNextClient.getSignUpUrl', () => { + const config: Record = { + applicationId: 'app-id', + baseUrl: 'https://api.asgardeo.io/t/acme', + clientId: 'client-id', + clientSecret: 'client-secret', + }; + + let client: AsgardeoNextClient; + + beforeAll(async () => { + legacyClient.getConfigData.mockResolvedValue(config); + legacyClient.initialize.mockResolvedValue(true); + + client = AsgardeoNextClient.getInstance(); + await client.initialize(config as any); + }); + + beforeEach(() => { + vi.clearAllMocks(); + legacyClient.getConfigData.mockResolvedValue(config); + }); + + it('derives the hosted self-registration page from the Asgardeo base URL', async () => { + const signUpUrl: URL = new URL(await client.getSignUpUrl()); + + expect(signUpUrl.origin).toBe('https://accounts.asgardeo.io'); + expect(signUpUrl.pathname).toBe('/t/acme/accountrecoveryendpoint/register.do'); + expect(signUpUrl.searchParams.get('client_id')).toBe('client-id'); + expect(signUpUrl.searchParams.get('spId')).toBe('app-id'); + }); + + it('prefers the configured signUpUrl', async () => { + legacyClient.getConfigData.mockResolvedValue({...config, signUpUrl: '/signup'}); + + await expect(client.getSignUpUrl()).resolves.toBe('/signup'); + }); + + it('returns an empty string when the base URL is not a recognised identity server pattern', async () => { + legacyClient.getConfigData.mockResolvedValue({...config, baseUrl: 'https://login.example.com'}); + + await expect(client.getSignUpUrl()).resolves.toBe(''); + }); + + it('still rejects a programmatic signUp(options) call, pointing at getSignUpUrl', async () => { + await expect(client.signUp({})).rejects.toThrow(/getSignUpUrl/); + }); +}); diff --git a/packages/nextjs/src/server/actions/__tests__/signUpAction.test.ts b/packages/nextjs/src/server/actions/__tests__/signUpAction.test.ts new file mode 100644 index 000000000..48e9f1d68 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/signUpAction.test.ts @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import AsgardeoNextClient from '../../../AsgardeoNextClient'; +import autoSignInAfterSignUp from '../../../utils/autoSignInAfterSignUp'; +import signUpAction from '../signUpAction'; + +vi.mock('../../../AsgardeoNextClient', () => ({ + default: { + getInstance: vi.fn(), + }, +})); + +vi.mock('../../../utils/autoSignInAfterSignUp', () => ({ + default: vi.fn(), + extractSignUpCredentials: vi.fn((inputs?: Record) => + inputs?.['username'] && inputs?.['password'] + ? {password: inputs['password'] as string, username: inputs['username'] as string} + : undefined, + ), +})); + +describe('signUpAction', () => { + type ActionResult = Awaited>; + + const storageManager: {getConfigDataParameter: Mock} = {getConfigDataParameter: vi.fn()}; + const client: {getSignUpUrl: Mock; getStorageManager: Mock; signUp: Mock} = { + getSignUpUrl: vi.fn(), + getStorageManager: vi.fn(async () => storageManager), + signUp: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + + (AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(client); + client.getStorageManager.mockResolvedValue(storageManager); + }); + + it('resolves the redirect-based sign-up URL when called without a payload', async () => { + const signUpUrl: string = + 'https://accounts.asgardeo.io/t/acme/accountrecoveryendpoint/register.do?client_id=client-id'; + + client.getSignUpUrl.mockResolvedValue(signUpUrl); + + const result: ActionResult = await signUpAction(); + + expect(result).toEqual({data: {signUpUrl}, success: true}); + expect(client.signUp).not.toHaveBeenCalled(); + }); + + it('reports an error when no sign-up URL can be resolved', async () => { + client.getSignUpUrl.mockResolvedValue(''); + + const result: ActionResult = await signUpAction(); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/signUpUrl/); + expect(result.data).toBeUndefined(); + }); + + it('returns the next step of an incomplete embedded flow', async () => { + const nextStep: Record = {flowId: 'flow-1', flowStatus: 'INCOMPLETE', type: 'VIEW'}; + + client.signUp.mockResolvedValue(nextStep); + + const payload: {flowType: string} = {flowType: 'REGISTRATION'}; + const result: ActionResult = await signUpAction(payload as any); + + expect(client.signUp).toHaveBeenCalledWith(payload); + expect(client.getSignUpUrl).not.toHaveBeenCalled(); + expect(result).toEqual({data: nextStep, success: true}); + }); + + it('signs the user in and returns the after-sign-up URL when the embedded flow completes', async () => { + client.signUp.mockResolvedValue({flowId: 'flow-1', flowStatus: 'COMPLETE'}); + storageManager.getConfigDataParameter.mockImplementation(async (name: string) => + name === 'afterSignUpUrl' ? 'http://localhost:3000/welcome' : undefined, + ); + (autoSignInAfterSignUp as unknown as Mock).mockResolvedValue({signedIn: true}); + + const result: ActionResult = await signUpAction({ + flowId: 'flow-1', + inputs: {password: 'secret', username: 'jane'}, + } as any); + + expect(autoSignInAfterSignUp).toHaveBeenCalledWith({password: 'secret', username: 'jane'}); + expect(result.success).toBe(true); + expect(result.data).toMatchObject({ + afterSignUpUrl: 'http://localhost:3000/welcome', + flowStatus: 'COMPLETE', + signedIn: true, + }); + }); +}); diff --git a/packages/nextjs/src/server/actions/signUpAction.ts b/packages/nextjs/src/server/actions/signUpAction.ts index fc083acdd..8926fc3fa 100644 --- a/packages/nextjs/src/server/actions/signUpAction.ts +++ b/packages/nextjs/src/server/actions/signUpAction.ts @@ -27,12 +27,14 @@ import autoSignInAfterSignUp, { } from '../../utils/autoSignInAfterSignUp'; /** - * Server action for signing in a user. - * Handles the embedded sign-in flow and manages session cookies. + * Server action for signing up a user. * - * @param payload - The embedded sign-in flow payload - * @param request - The embedded flow execute request config - * @returns Promise that resolves when sign-in is complete + * Without a payload it resolves the URL of the redirect-based sign-up page (the configured `signUpUrl`, or + * the identity server's self-registration page). With an embedded-flow payload it drives the embedded + * sign-up flow and signs the new user in when the flow completes. + * + * @param payload - The embedded sign-up flow payload + * @returns Promise that resolves with the sign-up URL, the next step of the embedded flow, or its completion */ const signUpAction = async ( payload?: EmbeddedFlowExecuteRequestPayload, @@ -49,13 +51,21 @@ const signUpAction = async ( try { const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); - // If no payload provided, redirect to sign-in URL for redirect-based sign-in. - // If there's a payload, handle the embedded sign-in flow. + // Without a payload, hand back the URL of the redirect-based sign-up page for the browser to navigate to. if (!payload) { - const defaultSignUpUrl: string = ''; + const signUpUrl: string = await client.getSignUpUrl(); + + if (!signUpUrl) { + return { + error: + 'No sign-up URL could be resolved for the configured `baseUrl`. Configure `signUpUrl` (or `NEXT_PUBLIC_ASGARDEO_SIGN_UP_URL`) to point at your sign-up page.', + success: false, + }; + } - return {data: {signUpUrl: String(defaultSignUpUrl)}, success: true}; + return {data: {signUpUrl}, success: true}; } + const response: any = await client.signUp(payload); if (response.flowStatus === EmbeddedFlowStatus.Complete) {