Skip to content

Commit 3d74c43

Browse files
committed
Let users verify their email address and mobile number from their account
Adds a VerifyAttribute component and its Base variant to React, Vue, Next.js and Nuxt. It runs the server's SELF_SERVICE attribute verification flow, started by flow handle with the user's token, and renders its steps from the flow response. UserProfile shows each contact's verification status and offers Verify for an unverified one. The core SDK adds executeEmbeddedSelfServiceFlow, the request parser the Next.js action and the Nuxt route use to forward a step, and helpers that read verifiedAttributes from the profile. Signed-off-by: Pasindu Yeshan <pasinduyeshan98@gmail.com>
1 parent 44e4f95 commit 3d74c43

97 files changed

Lines changed: 9182 additions & 61 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.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright 2026 The ThunderID Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import {beforeEach, describe, expect, it, Mock, vi} from 'vitest';
5+
import ThunderIDAPIError from '../../../errors/ThunderIDAPIError';
6+
import {EmbeddedFlowType} from '../../../models/embedded-flow';
7+
import {
8+
EmbeddedSelfServiceFlowRequest,
9+
EmbeddedSelfServiceFlowResponse,
10+
EmbeddedSelfServiceFlowStatus,
11+
} from '../../../models/embedded-self-service-flow';
12+
import executeEmbeddedSelfServiceFlow from '../executeEmbeddedSelfServiceFlow';
13+
14+
const URL = 'https://localhost:8090/flow/execute';
15+
16+
const initiate: EmbeddedSelfServiceFlowRequest = {
17+
flowType: EmbeddedFlowType.SelfService,
18+
handle: 'default-self-attribute-verification-flow',
19+
inputs: {attribute: 'email'},
20+
};
21+
22+
type Fetcher = (url: string, config: RequestInit) => Promise<Response>;
23+
24+
const flowResponse: EmbeddedSelfServiceFlowResponse = {
25+
data: {additionalData: {emailSent: 'true', maskedDestination: 'a•••@example.com'}},
26+
executionId: 'exec-1',
27+
flowStatus: EmbeddedSelfServiceFlowStatus.Incomplete,
28+
};
29+
30+
const lastRequest = (fetcher: Mock<Fetcher>): [string, RequestInit] =>
31+
fetcher.mock.calls[fetcher.mock.calls.length - 1] as [string, RequestInit];
32+
33+
describe('executeEmbeddedSelfServiceFlow', (): void => {
34+
let fetcher: Mock<Fetcher>;
35+
36+
beforeEach((): void => {
37+
fetcher = vi.fn<Fetcher>().mockResolvedValue({json: () => Promise.resolve(flowResponse), ok: true} as Response);
38+
});
39+
40+
it('starts the flow by type and handle with its inputs and returns the step', async (): Promise<void> => {
41+
await expect(executeEmbeddedSelfServiceFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(flowResponse);
42+
43+
const [url, init] = lastRequest(fetcher);
44+
expect(url).toBe(URL);
45+
expect(init.method).toBe('POST');
46+
expect(JSON.parse(init.body as string)).toEqual({
47+
flowType: 'SELF_SERVICE',
48+
handle: 'default-self-attribute-verification-flow',
49+
inputs: {attribute: 'email'},
50+
verbose: true,
51+
});
52+
expect(init.headers).toMatchObject({Accept: 'application/json', 'Content-Type': 'application/json'});
53+
});
54+
55+
it('continues an execution without naming the handle again', async (): Promise<void> => {
56+
await executeEmbeddedSelfServiceFlow({
57+
fetcher,
58+
payload: {action: 'verify', challengeToken: 'ct-1', executionId: 'exec-1', inputs: {otp: '123456'}},
59+
url: URL,
60+
});
61+
62+
expect(JSON.parse(lastRequest(fetcher)[1].body as string)).toEqual({
63+
action: 'verify',
64+
challengeToken: 'ct-1',
65+
executionId: 'exec-1',
66+
inputs: {otp: '123456'},
67+
verbose: true,
68+
});
69+
});
70+
71+
it('builds the endpoint from baseUrl when url is not given', async (): Promise<void> => {
72+
await executeEmbeddedSelfServiceFlow({baseUrl: 'https://localhost:8090', fetcher, payload: initiate});
73+
74+
expect(lastRequest(fetcher)[0]).toBe('https://localhost:8090/flow/execute');
75+
});
76+
77+
it('keeps caller headers while setting the JSON headers', async (): Promise<void> => {
78+
await executeEmbeddedSelfServiceFlow({
79+
fetcher,
80+
headers: {'X-Request-Id': 'req-1'},
81+
payload: initiate,
82+
url: URL,
83+
});
84+
85+
expect(lastRequest(fetcher)[1].headers).toMatchObject({
86+
'Content-Type': 'application/json',
87+
'X-Request-Id': 'req-1',
88+
});
89+
});
90+
91+
it('uses native fetch when no fetcher is given', async (): Promise<void> => {
92+
const nativeFetch = vi.fn().mockResolvedValue({json: () => Promise.resolve(flowResponse), ok: true});
93+
vi.stubGlobal('fetch', nativeFetch);
94+
95+
try {
96+
await executeEmbeddedSelfServiceFlow({payload: initiate, url: URL});
97+
} finally {
98+
vi.unstubAllGlobals();
99+
}
100+
101+
expect(nativeFetch).toHaveBeenCalledWith(URL, expect.objectContaining({method: 'POST'}));
102+
});
103+
104+
describe('validation', (): void => {
105+
it.each([
106+
['no url', {}],
107+
['an invalid url', {url: 'not-a-url'}],
108+
['an invalid baseUrl', {baseUrl: 'invalid'}],
109+
])('refuses %s before sending', async (_name: string, endpoint: Record<string, string>): Promise<void> => {
110+
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
111+
...endpoint,
112+
fetcher,
113+
payload: initiate,
114+
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;
115+
116+
expect(error).toBeInstanceOf(ThunderIDAPIError);
117+
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-001');
118+
expect(error.statusCode).toBe(400);
119+
expect(fetcher).not.toHaveBeenCalled();
120+
});
121+
122+
it('refuses a missing payload before sending', async (): Promise<void> => {
123+
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
124+
fetcher,
125+
payload: undefined as unknown as EmbeddedSelfServiceFlowRequest,
126+
url: URL,
127+
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;
128+
129+
expect(error).toBeInstanceOf(ThunderIDAPIError);
130+
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-002');
131+
expect(fetcher).not.toHaveBeenCalled();
132+
});
133+
134+
it.each<[string, EmbeddedSelfServiceFlowRequest]>([
135+
['no handle', {flowType: EmbeddedFlowType.SelfService, inputs: {attribute: 'email'}}],
136+
['an empty handle', {flowType: EmbeddedFlowType.SelfService, handle: '', inputs: {attribute: 'email'}}],
137+
])(
138+
'refuses a new flow with %s before sending',
139+
async (_name: string, payload: EmbeddedSelfServiceFlowRequest): Promise<void> => {
140+
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
141+
fetcher,
142+
payload,
143+
url: URL,
144+
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;
145+
146+
expect(error).toBeInstanceOf(ThunderIDAPIError);
147+
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ValidationError-003');
148+
expect(fetcher).not.toHaveBeenCalled();
149+
},
150+
);
151+
});
152+
153+
describe('response errors', (): void => {
154+
it('maps a failed response to an API error with its status', async (): Promise<void> => {
155+
fetcher.mockResolvedValue({
156+
ok: false,
157+
status: 401,
158+
statusText: 'Unauthorized',
159+
text: () => Promise.resolve('{"code":"AUTH-1001","message":"Unauthorized","description":"invalid token"}'),
160+
} as unknown as Response);
161+
162+
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
163+
fetcher,
164+
payload: initiate,
165+
url: URL,
166+
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;
167+
168+
expect(error).toBeInstanceOf(ThunderIDAPIError);
169+
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-ResponseError-001');
170+
expect(error.statusCode).toBe(401);
171+
expect(error.statusText).toBe('Unauthorized');
172+
expect(error.message).toMatch(/^Self-service flow request failed: /);
173+
});
174+
175+
it('maps a network failure to a network error', async (): Promise<void> => {
176+
fetcher.mockRejectedValue(new TypeError('Failed to fetch'));
177+
178+
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
179+
fetcher,
180+
payload: initiate,
181+
url: URL,
182+
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;
183+
184+
expect(error).toBeInstanceOf(ThunderIDAPIError);
185+
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-NetworkError-001');
186+
expect(error.statusCode).toBe(0);
187+
expect(error.message).toContain('Failed to fetch');
188+
});
189+
190+
it('maps an unreadable body to a network error', async (): Promise<void> => {
191+
fetcher.mockResolvedValue({
192+
json: () => Promise.reject(new SyntaxError('Unexpected token')),
193+
ok: true,
194+
} as unknown as Response);
195+
196+
const error: ThunderIDAPIError = (await executeEmbeddedSelfServiceFlow({
197+
fetcher,
198+
payload: initiate,
199+
url: URL,
200+
}).catch((e: unknown) => e as ThunderIDAPIError)) as ThunderIDAPIError;
201+
202+
expect(error.code).toBe('executeEmbeddedSelfServiceFlow-NetworkError-001');
203+
});
204+
205+
it('returns a flow error step as data rather than throwing', async (): Promise<void> => {
206+
const refused: EmbeddedSelfServiceFlowResponse = {
207+
error: {code: 'FET-1112'} as EmbeddedSelfServiceFlowResponse['error'],
208+
executionId: 'exec-1',
209+
flowStatus: EmbeddedSelfServiceFlowStatus.Error,
210+
};
211+
fetcher.mockResolvedValue({json: () => Promise.resolve(refused), ok: true} as unknown as Response);
212+
213+
await expect(executeEmbeddedSelfServiceFlow({fetcher, payload: initiate, url: URL})).resolves.toEqual(refused);
214+
});
215+
});
216+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2026 The ThunderID Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
5+
import {EmbeddedSelfServiceFlowRequest, EmbeddedSelfServiceFlowResponse} from '../../models/embedded-self-service-flow';
6+
7+
/**
8+
* Configuration for the executeEmbeddedSelfServiceFlow request.
9+
*/
10+
export interface ExecuteEmbeddedSelfServiceFlowConfig extends Omit<RequestInit, 'method' | 'body'> {
11+
/**
12+
* The base path of the API endpoint.
13+
*/
14+
baseUrl?: string;
15+
/**
16+
* Optional custom fetcher function. If not provided, native fetch is used, which sends no
17+
* access token; framework SDKs pass a fetcher that attaches the signed-in user's token.
18+
*/
19+
fetcher?: (url: string, config: RequestInit) => Promise<Response>;
20+
/**
21+
* The step to execute: an initiating request or a continuation.
22+
*/
23+
payload: EmbeddedSelfServiceFlowRequest;
24+
/**
25+
* The absolute flow execution endpoint.
26+
*/
27+
url?: string;
28+
}
29+
30+
/**
31+
* Executes one step of a self-service flow, an operation the signed-in user performs on their own
32+
* account, such as verifying their email address. A new execution names the flow by its handle; the
33+
* server takes the user from the access token on that first request and keeps it for the execution.
34+
*
35+
* @param config - Configuration object with URL, payload, fetcher and optional request config.
36+
* @returns A promise that resolves with the flow step.
37+
* @throws ThunderIDAPIError when the input is invalid or the request fails, coded
38+
* `executeEmbeddedSelfServiceFlow-` plus `ValidationError-001` (invalid `url` or `baseUrl`), `ValidationError-002`
39+
* (no payload), `ValidationError-003` (a new flow without a handle), `ResponseError-001` (the server refused, with
40+
* its status) or `NetworkError-001` (no response, or an unreadable one).
41+
*
42+
* @example
43+
* ```typescript
44+
* const step = await executeEmbeddedSelfServiceFlow({
45+
* baseUrl: 'https://localhost:8090',
46+
* payload: {
47+
* flowType: 'SELF_SERVICE',
48+
* handle: 'default-self-attribute-verification-flow',
49+
* inputs: {attribute: 'email'},
50+
* },
51+
* fetcher: authenticatedFetcher,
52+
* });
53+
*
54+
* await executeEmbeddedSelfServiceFlow({
55+
* baseUrl: 'https://localhost:8090',
56+
* payload: {executionId: step.executionId, action: 'verify', inputs: {otp: '123456'}},
57+
* fetcher: authenticatedFetcher,
58+
* });
59+
* ```
60+
*/
61+
const executeEmbeddedSelfServiceFlow = async ({
62+
url,
63+
baseUrl,
64+
payload,
65+
fetcher,
66+
...requestConfig
67+
}: ExecuteEmbeddedSelfServiceFlowConfig): Promise<EmbeddedSelfServiceFlowResponse> => {
68+
try {
69+
// eslint-disable-next-line no-new
70+
new URL((url ?? baseUrl)!);
71+
} catch (error) {
72+
throw new ThunderIDAPIError(
73+
`Invalid URL provided. ${error instanceof Error ? error.message : String(error)}`,
74+
'executeEmbeddedSelfServiceFlow-ValidationError-001',
75+
'javascript',
76+
400,
77+
'The provided `url` or `baseUrl` path does not adhere to the URL schema.',
78+
);
79+
}
80+
81+
if (!payload) {
82+
throw new ThunderIDAPIError(
83+
'Self-service flow payload is required',
84+
'executeEmbeddedSelfServiceFlow-ValidationError-002',
85+
'javascript',
86+
400,
87+
'If a self-service flow payload is not provided, the request cannot be constructed correctly.',
88+
);
89+
}
90+
91+
if (!payload.executionId && !payload.handle) {
92+
throw new ThunderIDAPIError(
93+
'The flow handle is required',
94+
'executeEmbeddedSelfServiceFlow-ValidationError-003',
95+
'javascript',
96+
400,
97+
'A new self-service flow must name the flow to start by its handle.',
98+
);
99+
}
100+
101+
const fetchFn: typeof fetch = fetcher ?? fetch;
102+
const resolvedUrl: string = url ?? `${baseUrl}/flow/execute`;
103+
104+
const requestInit: RequestInit = {
105+
...requestConfig,
106+
// Every step is rendered from `meta`, so always ask for it.
107+
body: JSON.stringify({...payload, verbose: true}),
108+
headers: {
109+
...requestConfig.headers,
110+
Accept: 'application/json',
111+
'Content-Type': 'application/json',
112+
},
113+
method: 'POST',
114+
};
115+
116+
try {
117+
const response: Response = await fetchFn(resolvedUrl, requestInit);
118+
119+
if (!response?.ok) {
120+
const errorText: string = await response.text();
121+
122+
throw new ThunderIDAPIError(
123+
errorText,
124+
'executeEmbeddedSelfServiceFlow-ResponseError-001',
125+
'javascript',
126+
response.status,
127+
response.statusText,
128+
'Self-service flow request failed',
129+
);
130+
}
131+
132+
return (await response.json()) as EmbeddedSelfServiceFlowResponse;
133+
} catch (error) {
134+
if (error instanceof ThunderIDAPIError) {
135+
throw error;
136+
}
137+
138+
throw new ThunderIDAPIError(
139+
`Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`,
140+
'executeEmbeddedSelfServiceFlow-NetworkError-001',
141+
'javascript',
142+
0,
143+
'Network Error',
144+
);
145+
}
146+
};
147+
148+
export default executeEmbeddedSelfServiceFlow;

0 commit comments

Comments
 (0)