Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
48db5ac
feat(clerk-js): Initial work for reset password task
octoper Nov 19, 2025
23c35e1
fix(clerk-js): Rename variable for clarity in SignInFactorOne component
octoper Nov 19, 2025
2cf92e9
revert(clerk-js): Remove the logic for sign-in error on untrusted pas…
octoper Nov 20, 2025
f61d444
refactor(clerk-js): Remove 'untrustedPasswordMethods' from FlowMetada…
octoper Nov 20, 2025
f771b6a
chore(repo): Add changeset
octoper Nov 20, 2025
b032b92
feat(clerk-js): Implement TaskResetPassword component and update cont…
octoper Nov 20, 2025
580026c
refactor(clerk-js): Simplify TaskResetPassword component by removing …
octoper Nov 20, 2025
ad66210
fix(clerk-js): Update localization keys in TaskResetPassword componen…
octoper Nov 20, 2025
e482de0
refactor(localization): Remove 'passwordUntrusted' key from en-US loc…
octoper Nov 20, 2025
d9a5d07
fix(clerk-js): Increase maxSize for sessionTasks.js in bundlewatch co…
octoper Nov 24, 2025
0dd1b54
refactor(clerk-js): Reorder import statements in TaskChooseOrganizati…
octoper Nov 24, 2025
2d03298
feat(clerk-js): Enhance tests and add localization key for the form b…
octoper Nov 24, 2025
ddf655d
feat(localization): Add 'taskResetPassword' localization keys
octoper Nov 24, 2025
98952e0
fix(clerk-js): When we are on a url for a task that not exists anymor…
octoper Nov 24, 2025
3c1b5f1
chore(clerk-js): Remove unused context import from TaskResetPassword …
octoper Nov 24, 2025
036c93a
fix(clerk-js,localization): Remove redundant test for task order and …
octoper Nov 25, 2025
84bc6aa
fix(clerk-js): Simplify navigation logic in session task components a…
octoper Nov 26, 2025
05a57cc
fix(clerk-js): Update buildTasksUrl method to accept optional redirec…
octoper Nov 26, 2025
e6fc673
fix(clerk-js): Update buildTasksUrl method to accept optional TasksRe…
octoper Nov 26, 2025
e4febd7
feat(clerk-js,backend): Implement reset password session task and rel…
octoper Nov 26, 2025
0f33ec8
tests(e2e): Update tests
octoper Nov 26, 2025
baa5c2b
fix(clerk-js): Revert navigation changes from TaskChooseOrganization
octoper Nov 26, 2025
bea6e4b
chore(clerk-js): Remove unused usage
octoper Nov 26, 2025
205ec3b
Fix navigation for tasks within virtual router
LauraBeatris Nov 26, 2025
2d6b3e2
Fix navigation to n+1 task within modal
LauraBeatris Nov 26, 2025
a8b5605
Move `withTaskGuard` to shared folder
LauraBeatris Nov 26, 2025
e515c50
Fix `withTaskGuard` import path
LauraBeatris Nov 26, 2025
d971e71
Render session task context for separate components
LauraBeatris Nov 26, 2025
7e3dcf8
fix(clerk-js): Update token handling in Session class to ensure corre…
octoper Nov 27, 2025
ad7815c
fix(clerk-js): Enhance token resolve to ensure accurate token retriev…
octoper Nov 28, 2025
ad95b14
fix(clerk-js): Update token caching to use Promise.resolve for last a…
octoper Nov 28, 2025
3e97845
wip
octoper Nov 28, 2025
579ca04
fix(clerk-js): Revert changes
octoper Nov 28, 2025
6a2ba9a
revert changes
octoper Nov 28, 2025
e7cc9d9
revert changes
octoper Nov 28, 2025
f4d8faa
fix: Revert buildTasksUrl changes
octoper Nov 28, 2025
1cf4496
fix(clerk-js): Prevent components unmounting when current task changes
octoper Nov 28, 2025
92f503b
fix(clerk-js): Prevent double navigation when resolving the last task
octoper Nov 28, 2025
f871d5a
fix(clerk-js): Remove uneeded changes
octoper Nov 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/loose-brooms-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Introduce `reset-password` session task
5 changes: 5 additions & 0 deletions .changeset/thick-dancers-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': minor
---

Introducing `users.__experimental_passwordUntrusted` action
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ const withSessionTasks = base
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withSessionTasksResetPassword = base
.clone()
.setId('withSessionTasksResetPassword')
.setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-reset-password').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-reset-password').pk);

const withBillingJwtV2 = base
.clone()
.setId('withBillingJwtV2')
Expand Down Expand Up @@ -203,6 +210,7 @@ export const envs = {
withRestrictedMode,
withReverification,
withSessionTasks,
withSessionTasksResetPassword,
withSignInOrUpEmailLinksFlow,
withSignInOrUpFlow,
withSignInOrUpwithRestrictedModeFlow,
Expand Down
11 changes: 6 additions & 5 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ export const createLongRunningApps = () => {
{ id: 'next.appRouter.withSignInOrUpFlow', config: next.appRouter, env: envs.withSignInOrUpFlow },
{ id: 'next.appRouter.withSignInOrUpEmailLinksFlow', config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow },
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
{ id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword },
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },

/**
* Quickstart apps
*/
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },

/**
/**
* Billing apps
*/
{ id: 'withBillingJwtV2.next.appRouter', config: next.appRouter, env: envs.withBillingJwtV2 },
Expand All @@ -60,14 +61,14 @@ export const createLongRunningApps = () => {
{ id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks },
{ id: 'vue.vite', config: vue.vite, env: envs.withCustomRoles },

/**
/**
* Tanstack apps - basic flows
*/
{ id: 'tanstack.react-start', config: tanstack.reactStart, env: envs.withEmailCodes },

/**
* Various apps - basic flows
*/
*/
{ id: 'withBilling.astro.node', config: astro.node, env: envs.withBilling },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
{ id: 'astro.static.withCustomRoles', config: astro.static, env: envs.withCustomRoles },
Expand All @@ -80,7 +81,7 @@ export const createLongRunningApps = () => {

const apps = configs.map(longRunningApplication);

return {
return {
getByPattern: (patterns: Array<string | (typeof configs)[number]['id']>) => {
const res = new Set(patterns.map(pattern => apps.filter(app => idMatchesPattern(app.id, pattern))).flat());
if (!res.size) {
Expand Down
9 changes: 9 additions & 0 deletions integration/testUtils/organizationsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type FakeOrganization = Pick<Organization, 'slug' | 'name'>;
export type OrganizationService = {
deleteAll: () => Promise<void>;
createFakeOrganization: () => FakeOrganization;
createBapiOrganization: (fakeOrganization: FakeOrganization & { createdBy: string }) => Promise<Organization>;
};

export const createOrganizationsService = (clerkClient: ClerkClient) => {
Expand All @@ -19,6 +20,14 @@ export const createOrganizationsService = (clerkClient: ClerkClient) => {
const bulkDeletionPromises = organizations.data.map(({ id }) => clerkClient.organizations.deleteOrganization(id));
await Promise.all(bulkDeletionPromises);
},
createBapiOrganization: async (fakeOrganization: FakeOrganization & { createdBy: string }) => {
const organization = await clerkClient.organizations.createOrganization({
name: fakeOrganization.name,
slug: fakeOrganization.slug,
createdBy: fakeOrganization.createdBy,
});
return organization;
},
};

return self;
Expand Down
4 changes: 4 additions & 0 deletions integration/testUtils/usersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export type UserService = {
createFakeOrganization: (userId: string) => Promise<FakeOrganization>;
getUser: (opts: { id?: string; email?: string }) => Promise<User | undefined>;
createFakeAPIKey: (userId: string) => Promise<FakeAPIKey>;
passwordUntrusted: (userId: string) => Promise<void>;
};

/**
Expand Down Expand Up @@ -210,6 +211,9 @@ export const createUserService = (clerkClient: ClerkClient) => {
revoke: () => clerkClient.apiKeys.revoke({ apiKeyId: apiKey.id, revocationReason: 'For testing purposes' }),
} satisfies FakeAPIKey;
},
passwordUntrusted: async (userId: string) => {
await clerkClient.users.__experimental_passwordUntrusted(userId);
},
};

return self;
Expand Down
99 changes: 99 additions & 0 deletions integration/tests/session-tasks-sign-in-reset-password.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { test } from '@playwright/test';

import { hash } from '../models/helpers';
import { appConfigs } from '../presets';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksResetPassword] })(
'session tasks after sign-in reset password flow @nextjs',
({ app }) => {
test.describe.configure({ mode: 'parallel' });

test.afterAll(async () => {
await app.teardown();
});

test('resolve both reset password and organization selection tasks after sign-in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

const user = u.services.users.createFakeUser();
const createdUser = await u.services.users.createBapiUser(user);

await u.services.users.passwordUntrusted(createdUser.id);

// Performs sign-in
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(user.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(user.password);
await u.po.signIn.continue();

await u.page.getByRole('textbox', { name: 'code' }).click();
await u.page.keyboard.type('424242', { delay: 100 });

// Redirects back to tasks when accessing protected route by `auth.protect`
await u.page.goToRelative('/page-protected');

const newPassword = `${hash()}_testtest`;
await u.po.sessionTask.resolveResetPasswordTask({
newPassword: newPassword,
confirmPassword: newPassword,
});

await u.po.sessionTask.resolveForceOrganizationSelectionTask({
name: 'Test Organization',
});

// Navigates to after sign-in
await u.page.waitForAppUrl('/page-protected');

await u.page.signOut();
await u.page.context().clearCookies();

await user.deleteIfExists();
await u.services.organizations.deleteAll();
});

test('sign-in with email and resolve the reset password task', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser();
const createdUser = await u.services.users.createBapiUser(user);

await u.services.users.passwordUntrusted(createdUser.id);
const fakeOrganization = u.services.organizations.createFakeOrganization();
await u.services.organizations.createBapiOrganization({
...fakeOrganization,
createdBy: createdUser.id,
});

// Performs sign-in
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(user.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(user.password);
await u.po.signIn.continue();

await u.page.getByRole('textbox', { name: 'code' }).fill('424242');

await u.po.expect.toBeSignedIn();

// Redirects back to tasks when accessing protected route by `auth.protect`
await u.page.goToRelative('/page-protected');

const newPassword = `${hash()}_testtest`;
await u.po.sessionTask.resolveResetPasswordTask({
newPassword: newPassword,
confirmPassword: newPassword,
});

// Navigates to after sign-in
await u.page.waitForAppUrl('/page-protected');

await u.page.signOut();
await u.page.context().clearCookies();

await user.deleteIfExists();
await u.services.organizations.deleteAll();
});
},
);
11 changes: 11 additions & 0 deletions packages/backend/src/api/endpoints/UserApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,4 +447,15 @@ export class UserAPI extends AbstractAPI {
path: joinPaths(basePath, userId, 'totp'),
});
}

public async __experimental_passwordUntrusted(userId: string) {
this.requireId(userId);
return this.request<User>({
method: 'POST',
path: joinPaths(basePath, userId, 'password_untrusted'),
bodyParams: {
revokeAllSessions: false,
},
});
}
}
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@
{ "path": "./dist/op-plans-page*.js", "maxSize": "1.0KB" },
{ "path": "./dist/statement-page*.js", "maxSize": "1.0KB" },
{ "path": "./dist/payment-attempt-page*.js", "maxSize": "3.0KB" },
{ "path": "./dist/sessionTasks*.js", "maxSize": "1.5KB" }
{ "path": "./dist/sessionTasks*.js", "maxSize": "3.0KB" }
]
}
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/sessionTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildURL, forwardClerkQueryParams } from '../utils';
*/
export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = {
'choose-organization': 'choose-organization',
'reset-password': 'reset-password',
} as const;

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/clerk-js/src/test/fixture-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const createUserFixtureHelpers = (baseClient: ClientJSON) => {
Partial<UserJSON>,
'email_addresses' | 'phone_numbers' | 'external_accounts' | 'saml_accounts' | 'organization_memberships'
> & {
identifier?: string;
email_addresses?: Array<string | Partial<EmailAddressJSON>>;
phone_numbers?: Array<string | Partial<PhoneNumberJSON>>;
external_accounts?: Array<OAuthProvider | Partial<ExternalAccountJSON>>;
Expand All @@ -59,7 +60,7 @@ const createUserFixtureHelpers = (baseClient: ClientJSON) => {
first_name: 'FirstName',
last_name: 'LastName',
image_url: '',
identifier: 'email@test.com',
identifier: params.identifier || 'email@test.com',
user_id: '',
...params,
} as PublicUserDataJSON;
Expand Down
22 changes: 11 additions & 11 deletions packages/clerk-js/src/ui/components/SessionTasks/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useClerk } from '@clerk/shared/react';
import { eventComponentMounted } from '@clerk/shared/telemetry';
import type { SessionResource } from '@clerk/shared/types';
import { useEffect, useRef } from 'react';

import { Flow } from '@/ui/customizables';
Expand All @@ -12,10 +11,12 @@ import { INTERNAL_SESSION_TASK_ROUTE_BY_KEY } from '../../../core/sessionTasks';
import {
SessionTasksContext,
TaskChooseOrganizationContext,
TaskResetPasswordContext,
useSessionTasksContext,
} from '../../contexts/components/SessionTasks';
import { Route, Switch, useRouter } from '../../router';
import { TaskChooseOrganization } from './tasks/TaskChooseOrganization';
import { TaskResetPassword } from './tasks/TaskResetPassword';

const SessionTasksStart = () => {
const clerk = useClerk();
Expand Down Expand Up @@ -60,6 +61,13 @@ function SessionTasksRoutes(): JSX.Element {
<TaskChooseOrganization />
</TaskChooseOrganizationContext.Provider>
</Route>
<Route path={INTERNAL_SESSION_TASK_ROUTE_BY_KEY['reset-password']}>
<TaskResetPasswordContext.Provider
value={{ componentName: 'TaskResetPassword', redirectUrlComplete: ctx.redirectUrlComplete }}
>
<TaskResetPassword />
</TaskResetPasswordContext.Provider>
</Route>
<Route index>
<SessionTasksStart />
</Route>
Expand All @@ -78,6 +86,7 @@ type SessionTasksProps = {
export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: SessionTasksProps) => {
const clerk = useClerk();
const { navigate } = useRouter();

const currentTaskContainer = useRef<HTMLDivElement>(null);

// If there are no pending tasks, navigate away from the tasks flow.
Expand Down Expand Up @@ -111,17 +120,8 @@ export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: Sess
);
}

const navigateOnSetActive = async ({ session }: { session: SessionResource }) => {
const currentTask = session.currentTask;
if (!currentTask) {
return navigate(redirectUrlComplete);
}

return navigate(`./${INTERNAL_SESSION_TASK_ROUTE_BY_KEY[currentTask.key]}`);
};

return (
<SessionTasksContext.Provider value={{ redirectUrlComplete, currentTaskContainer, navigateOnSetActive }}>
<SessionTasksContext.Provider value={{ redirectUrlComplete }}>
<SessionTasksRoutes />
</SessionTasksContext.Provider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
sharedMainIdentifierSx,
} from '@/ui/common/organizations/OrganizationPreview';
import { organizationListParams, populateCacheUpdateItem } from '@/ui/components/OrganizationSwitcher/utils';
import { useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks';
import { useSessionTasksContext, useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks';
import { Col, descriptors, localizationKeys, Text, useLocalizations } from '@/ui/customizables';
import { Action, Actions } from '@/ui/elements/Actions';
import { Card } from '@/ui/elements/Card';
Expand All @@ -25,7 +25,6 @@ import { Header } from '@/ui/elements/Header';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView';
import { Add } from '@/ui/icons';
import { useRouter } from '@/ui/router';
import { handleError } from '@/ui/utils/errorHandler';

type ChooseOrganizationScreenProps = {
Expand Down Expand Up @@ -107,7 +106,7 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) =
const MembershipPreview = (props: { organization: OrganizationResource }) => {
const { user } = useUser();
const card = useCardState();
const { navigate } = useRouter();
const { navigateOnSetActive } = useSessionTasksContext();
const { redirectUrlComplete } = useTaskChooseOrganizationContext();
const { isLoaded, setActive } = useOrganizationList();
const { t } = useLocalizations();
Expand All @@ -121,9 +120,8 @@ const MembershipPreview = (props: { organization: OrganizationResource }) => {
try {
await setActive({
organization,
navigate: async () => {
// TODO(after-auth) ORGS-779 - Handle next tasks
await navigate(redirectUrlComplete);
navigate: async ({ session }) => {
await navigateOnSetActive?.({ session, redirectUrlComplete });
},
});
} catch (err) {
Expand Down
Loading