Skip to content

Commit d4aef71

Browse files
feat(clerk-js): Introduce reset password session task (#7268)
Co-authored-by: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com>
1 parent 07a30ce commit d4aef71

Some content is hidden

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

79 files changed

+1034
-54
lines changed

.changeset/loose-brooms-occur.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/shared': minor
5+
---
6+
7+
Introduce `reset-password` session task

.changeset/thick-dancers-battle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': minor
3+
---
4+
5+
Introducing `users.__experimental_passwordUntrusted` action

integration/presets/envs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ const withSessionTasks = base
151151
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
152152
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');
153153

154+
const withSessionTasksResetPassword = base
155+
.clone()
156+
.setId('withSessionTasksResetPassword')
157+
.setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev')
158+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-reset-password').sk)
159+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-reset-password').pk);
160+
154161
const withBillingJwtV2 = base
155162
.clone()
156163
.setId('withBillingJwtV2')
@@ -203,6 +210,7 @@ export const envs = {
203210
withRestrictedMode,
204211
withReverification,
205212
withSessionTasks,
213+
withSessionTasksResetPassword,
206214
withSignInOrUpEmailLinksFlow,
207215
withSignInOrUpFlow,
208216
withSignInOrUpwithRestrictedModeFlow,

integration/presets/longRunningApps.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ export const createLongRunningApps = () => {
3131
{ id: 'next.appRouter.withSignInOrUpFlow', config: next.appRouter, env: envs.withSignInOrUpFlow },
3232
{ id: 'next.appRouter.withSignInOrUpEmailLinksFlow', config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow },
3333
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
34+
{ id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword },
3435
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },
3536

3637
/**
3738
* Quickstart apps
3839
*/
3940
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
4041

41-
/**
42+
/**
4243
* Billing apps
4344
*/
4445
{ id: 'withBillingJwtV2.next.appRouter', config: next.appRouter, env: envs.withBillingJwtV2 },
@@ -60,14 +61,14 @@ export const createLongRunningApps = () => {
6061
{ id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks },
6162
{ id: 'vue.vite', config: vue.vite, env: envs.withCustomRoles },
6263

63-
/**
64+
/**
6465
* Tanstack apps - basic flows
6566
*/
6667
{ id: 'tanstack.react-start', config: tanstack.reactStart, env: envs.withEmailCodes },
67-
68+
6869
/**
6970
* Various apps - basic flows
70-
*/
71+
*/
7172
{ id: 'withBilling.astro.node', config: astro.node, env: envs.withBilling },
7273
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
7374
{ id: 'astro.static.withCustomRoles', config: astro.static, env: envs.withCustomRoles },
@@ -80,7 +81,7 @@ export const createLongRunningApps = () => {
8081

8182
const apps = configs.map(longRunningApplication);
8283

83-
return {
84+
return {
8485
getByPattern: (patterns: Array<string | (typeof configs)[number]['id']>) => {
8586
const res = new Set(patterns.map(pattern => apps.filter(app => idMatchesPattern(app.id, pattern))).flat());
8687
if (!res.size) {

integration/testUtils/organizationsService.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type FakeOrganization = Pick<Organization, 'slug' | 'name'>;
66
export type OrganizationService = {
77
deleteAll: () => Promise<void>;
88
createFakeOrganization: () => FakeOrganization;
9+
createBapiOrganization: (fakeOrganization: FakeOrganization & { createdBy: string }) => Promise<Organization>;
910
};
1011

1112
export const createOrganizationsService = (clerkClient: ClerkClient) => {
@@ -19,6 +20,14 @@ export const createOrganizationsService = (clerkClient: ClerkClient) => {
1920
const bulkDeletionPromises = organizations.data.map(({ id }) => clerkClient.organizations.deleteOrganization(id));
2021
await Promise.all(bulkDeletionPromises);
2122
},
23+
createBapiOrganization: async (fakeOrganization: FakeOrganization & { createdBy: string }) => {
24+
const organization = await clerkClient.organizations.createOrganization({
25+
name: fakeOrganization.name,
26+
slug: fakeOrganization.slug,
27+
createdBy: fakeOrganization.createdBy,
28+
});
29+
return organization;
30+
},
2231
};
2332

2433
return self;

integration/testUtils/usersService.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export type UserService = {
7676
createFakeOrganization: (userId: string) => Promise<FakeOrganization>;
7777
getUser: (opts: { id?: string; email?: string }) => Promise<User | undefined>;
7878
createFakeAPIKey: (userId: string) => Promise<FakeAPIKey>;
79+
passwordUntrusted: (userId: string) => Promise<void>;
7980
};
8081

8182
/**
@@ -210,6 +211,9 @@ export const createUserService = (clerkClient: ClerkClient) => {
210211
revoke: () => clerkClient.apiKeys.revoke({ apiKeyId: apiKey.id, revocationReason: 'For testing purposes' }),
211212
} satisfies FakeAPIKey;
212213
},
214+
passwordUntrusted: async (userId: string) => {
215+
await clerkClient.users.__experimental_passwordUntrusted(userId);
216+
},
213217
};
214218

215219
return self;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { test } from '@playwright/test';
2+
3+
import { hash } from '../models/helpers';
4+
import { appConfigs } from '../presets';
5+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
6+
7+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksResetPassword] })(
8+
'session tasks after sign-in reset password flow @nextjs',
9+
({ app }) => {
10+
test.describe.configure({ mode: 'parallel' });
11+
12+
test.afterAll(async () => {
13+
await app.teardown();
14+
});
15+
16+
test('resolve both reset password and organization selection tasks after sign-in', async ({ page, context }) => {
17+
const u = createTestUtils({ app, page, context });
18+
19+
const user = u.services.users.createFakeUser();
20+
const createdUser = await u.services.users.createBapiUser(user);
21+
22+
await u.services.users.passwordUntrusted(createdUser.id);
23+
24+
// Performs sign-in
25+
await u.po.signIn.goTo();
26+
await u.po.signIn.setIdentifier(user.email);
27+
await u.po.signIn.continue();
28+
await u.po.signIn.setPassword(user.password);
29+
await u.po.signIn.continue();
30+
31+
await u.page.getByRole('textbox', { name: 'code' }).click();
32+
await u.page.keyboard.type('424242', { delay: 100 });
33+
34+
// Redirects back to tasks when accessing protected route by `auth.protect`
35+
await u.page.goToRelative('/page-protected');
36+
37+
const newPassword = `${hash()}_testtest`;
38+
await u.po.sessionTask.resolveResetPasswordTask({
39+
newPassword: newPassword,
40+
confirmPassword: newPassword,
41+
});
42+
43+
await u.po.sessionTask.resolveForceOrganizationSelectionTask({
44+
name: 'Test Organization',
45+
});
46+
47+
// Navigates to after sign-in
48+
await u.page.waitForAppUrl('/page-protected');
49+
50+
await u.page.signOut();
51+
await u.page.context().clearCookies();
52+
53+
await user.deleteIfExists();
54+
await u.services.organizations.deleteAll();
55+
});
56+
57+
test('sign-in with email and resolve the reset password task', async ({ page, context }) => {
58+
const u = createTestUtils({ app, page, context });
59+
const user = u.services.users.createFakeUser();
60+
const createdUser = await u.services.users.createBapiUser(user);
61+
62+
await u.services.users.passwordUntrusted(createdUser.id);
63+
const fakeOrganization = u.services.organizations.createFakeOrganization();
64+
await u.services.organizations.createBapiOrganization({
65+
...fakeOrganization,
66+
createdBy: createdUser.id,
67+
});
68+
69+
// Performs sign-in
70+
await u.po.signIn.goTo();
71+
await u.po.signIn.setIdentifier(user.email);
72+
await u.po.signIn.continue();
73+
await u.po.signIn.setPassword(user.password);
74+
await u.po.signIn.continue();
75+
76+
await u.page.getByRole('textbox', { name: 'code' }).fill('424242');
77+
78+
await u.po.expect.toBeSignedIn();
79+
80+
// Redirects back to tasks when accessing protected route by `auth.protect`
81+
await u.page.goToRelative('/page-protected');
82+
83+
const newPassword = `${hash()}_testtest`;
84+
await u.po.sessionTask.resolveResetPasswordTask({
85+
newPassword: newPassword,
86+
confirmPassword: newPassword,
87+
});
88+
89+
// Navigates to after sign-in
90+
await u.page.waitForAppUrl('/page-protected');
91+
92+
await u.page.signOut();
93+
await u.page.context().clearCookies();
94+
95+
await user.deleteIfExists();
96+
await u.services.organizations.deleteAll();
97+
});
98+
},
99+
);

packages/backend/src/api/endpoints/UserApi.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,4 +447,15 @@ export class UserAPI extends AbstractAPI {
447447
path: joinPaths(basePath, userId, 'totp'),
448448
});
449449
}
450+
451+
public async __experimental_passwordUntrusted(userId: string) {
452+
this.requireId(userId);
453+
return this.request<User>({
454+
method: 'POST',
455+
path: joinPaths(basePath, userId, 'password_untrusted'),
456+
bodyParams: {
457+
revokeAllSessions: false,
458+
},
459+
});
460+
}
450461
}

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@
3131
{ "path": "./dist/op-plans-page*.js", "maxSize": "1.0KB" },
3232
{ "path": "./dist/statement-page*.js", "maxSize": "1.0KB" },
3333
{ "path": "./dist/payment-attempt-page*.js", "maxSize": "3.0KB" },
34-
{ "path": "./dist/sessionTasks*.js", "maxSize": "1.5KB" }
34+
{ "path": "./dist/sessionTasks*.js", "maxSize": "3.0KB" }
3535
]
3636
}

packages/clerk-js/src/core/sessionTasks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { buildURL, forwardClerkQueryParams } from '../utils';
88
*/
99
export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = {
1010
'choose-organization': 'choose-organization',
11+
'reset-password': 'reset-password',
1112
} as const;
1213

1314
/**

0 commit comments

Comments
 (0)