Skip to content

Commit 8538cd0

Browse files
authored
feat(clerk-js): Expose password validation from SignIn and SignUp resources (#1445)
* feat(clerk-js): Expose password validation from SignIn and SignUp resources * chore(clerk-js): Use ZxcvbnResult from `@types` * chore(repo): Example usage password validation in custom flows
1 parent 4ea30e8 commit 8538cd0

File tree

16 files changed

+277
-161
lines changed

16 files changed

+277
-161
lines changed

.changeset/nice-experts-hope.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Introducing validatePassword for SignIn and SignUp resources
7+
- Validate a password based on the instance's configuration set in Password Policies in Dashboard

packages/clerk-js/src/core/resources/SignIn.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
} from '@clerk/types';
3030

3131
import { generateSignatureWithMetamask, getMetamaskIdentifier, windowNavigate } from '../../utils';
32+
import { createValidatePassword } from '../../utils/passwords/password';
3233
import {
3334
clerkInvalidFAPIResponse,
3435
clerkInvalidStrategy,
@@ -234,6 +235,15 @@ export class SignIn extends BaseResource implements SignInResource {
234235
});
235236
};
236237

238+
validatePassword: ReturnType<typeof createValidatePassword> = (password, cb) => {
239+
if (SignIn.clerk.__unstable__environment?.userSettings.passwordSettings) {
240+
return createValidatePassword({
241+
...(SignIn.clerk.__unstable__environment?.userSettings.passwordSettings as any),
242+
validatePassword: true,
243+
})(password, cb);
244+
}
245+
};
246+
237247
protected fromJSON(data: SignInJSON | null): this {
238248
if (data) {
239249
this.id = data.id;

packages/clerk-js/src/core/resources/SignUp.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
} from '@clerk/types';
2323

2424
import { generateSignatureWithMetamask, getCaptchaToken, getMetamaskIdentifier, windowNavigate } from '../../utils';
25+
import { createValidatePassword } from '../../utils/passwords/password';
2526
import { normalizeUnsafeMetadata } from '../../utils/resourceParams';
2627
import {
2728
clerkInvalidFAPIResponse,
@@ -230,6 +231,15 @@ export class SignUp extends BaseResource implements SignUpResource {
230231
});
231232
};
232233

234+
validatePassword: ReturnType<typeof createValidatePassword> = (password, cb) => {
235+
if (SignUp.clerk.__unstable__environment?.userSettings.passwordSettings) {
236+
return createValidatePassword({
237+
...(SignUp.clerk.__unstable__environment?.userSettings.passwordSettings as any),
238+
validatePassword: true,
239+
})(password, cb);
240+
}
241+
};
242+
233243
protected fromJSON(data: SignUpJSON | null): this {
234244
if (data) {
235245
this.id = data.id;

packages/clerk-js/src/ui/hooks/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ export * from './useEnabledThirdPartyProviders';
77
export * from './useLoadingStatus';
88
export * from './usePassword';
99
export * from './usePasswordComplexity';
10-
export * from './usePasswordStrength';
1110
export * from './usePopover';
1211
export * from './usePrefersReducedMotion';
1312
export * from './useLocalStorage';

packages/clerk-js/src/ui/hooks/usePassword.ts

Lines changed: 4 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,15 @@
11
import { noop } from '@clerk/shared';
2-
import type { PasswordSettingsData } from '@clerk/types';
2+
import type { PasswordValidation } from '@clerk/types';
33
import { useCallback, useMemo } from 'react';
44

5+
import type { UsePasswordCbs, UsePasswordConfig } from '../../utils/passwords/password';
6+
import { createValidatePassword } from '../../utils/passwords/password';
57
import { localizationKeys, useLocalizations } from '../localization';
68
import type { FormControlState } from '../utils';
7-
import { loadZxcvbn } from '../utils';
8-
import type { ComplexityErrors } from './usePasswordComplexity';
9-
import { createValidateComplexity, generateErrorTextUtil } from './usePasswordComplexity';
10-
import type { PasswordStrength } from './usePasswordStrength';
11-
import { createValidatePasswordStrength } from './usePasswordStrength';
12-
13-
type UsePasswordConfig = PasswordSettingsData & {
14-
validatePassword: boolean;
15-
};
16-
17-
type PasswordValidation = {
18-
complexity?: ComplexityErrors;
19-
strength?: PasswordStrength;
20-
};
21-
22-
type UsePasswordCbs = {
23-
onValidationFailed?: (errorMessage: string | undefined) => void;
24-
onValidationSuccess?: () => void;
25-
onValidationWarning?: (warningMessage: string) => void;
26-
onValidationComplexity?: (b: boolean) => void;
27-
};
28-
29-
type ValidatePasswordCbs = {
30-
onValidation?: (res: PasswordValidation) => void;
31-
onValidationComplexity?: (b: boolean) => void;
32-
};
9+
import { generateErrorTextUtil } from './usePasswordComplexity';
3310

3411
export const MIN_PASSWORD_LENGTH = 8;
3512

36-
const createValidatePassword = (config: UsePasswordConfig, callbacks?: ValidatePasswordCbs) => {
37-
const { onValidation = noop, onValidationComplexity = noop } = callbacks || {};
38-
const { show_zxcvbn, validatePassword: validatePasswordProp } = config;
39-
const getComplexity = createValidateComplexity(config);
40-
const getScore = createValidatePasswordStrength(config);
41-
let result = {} satisfies PasswordValidation;
42-
43-
return (password: string) => {
44-
if (!validatePasswordProp) {
45-
return;
46-
}
47-
48-
/**
49-
* Validate Complexity
50-
*/
51-
const failedValidationsComplexity = getComplexity(password);
52-
onValidationComplexity(Object.keys(failedValidationsComplexity).length === 0);
53-
result = {
54-
...result,
55-
complexity: failedValidationsComplexity,
56-
};
57-
/**
58-
* Validate score
59-
*/
60-
if (show_zxcvbn) {
61-
/**
62-
* Lazy load zxcvbn without preventing a complexityError to be thrown if it exists
63-
*/
64-
void loadZxcvbn().then(zxcvbn => {
65-
const setPasswordScore = getScore(zxcvbn);
66-
const strength = setPasswordScore(password);
67-
68-
result = {
69-
...result,
70-
strength,
71-
};
72-
onValidation({
73-
...result,
74-
strength,
75-
});
76-
});
77-
}
78-
79-
onValidation({
80-
...result,
81-
complexity: failedValidationsComplexity,
82-
});
83-
};
84-
};
85-
8613
export const usePassword = (config: UsePasswordConfig, callbacks?: UsePasswordCbs) => {
8714
const { t, locale } = useLocalizations();
8815
const {

packages/clerk-js/src/ui/hooks/usePasswordComplexity.ts

Lines changed: 3 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,12 @@
1-
import type { PasswordSettingsData } from '@clerk/types';
21
import { useCallback, useEffect, useMemo, useState } from 'react';
32

3+
import type { ComplexityErrors, UsePasswordComplexityConfig } from '../../utils/passwords/complexity';
4+
import { validate } from '../../utils/passwords/complexity';
45
import type { LocalizationKey } from '../localization';
56
import { localizationKeys, useLocalizations } from '../localization';
67
import { addFullStop, createListFormat } from '../utils';
78

8-
export type ComplexityErrors = {
9-
[key in keyof Partial<Omit<PasswordSettingsData, 'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn'>>]?: boolean;
10-
};
11-
12-
type UsePasswordComplexityConfig = Omit<PasswordSettingsData, 'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn'>;
13-
14-
const createTestComplexityCases = (config: Pick<UsePasswordComplexityConfig, 'allowed_special_characters'>) => {
15-
let specialCharsRegex: RegExp;
16-
if (config.allowed_special_characters) {
17-
// Avoid a nested group by escaping the `[]` characters
18-
let escaped = config.allowed_special_characters.replace('[', '\\[');
19-
escaped = escaped.replace(']', '\\]');
20-
specialCharsRegex = new RegExp(`[${escaped}]`);
21-
} else {
22-
specialCharsRegex = /[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/;
23-
}
24-
25-
return (
26-
password: string,
27-
{
28-
minLength,
29-
maxLength,
30-
}: {
31-
minLength: number;
32-
maxLength: number;
33-
},
34-
) => {
35-
return {
36-
max_length: password.length < maxLength,
37-
min_length: password.length >= minLength,
38-
require_numbers: /\d/.test(password),
39-
require_lowercase: /[a-z]/.test(password),
40-
require_uppercase: /[A-Z]/.test(password),
41-
require_special_char: specialCharsRegex.test(password),
42-
};
43-
};
44-
};
45-
46-
const errorMessages = {
9+
const errorMessages: Record<keyof Omit<ComplexityErrors, 'allowed_special_characters'>, [string, string] | string> = {
4710
max_length: ['unstable__errors.passwordComplexity.maximumLength', 'length'],
4811
min_length: ['unstable__errors.passwordComplexity.minimumLength', 'length'],
4912
require_numbers: 'unstable__errors.passwordComplexity.requireNumbers',
@@ -89,44 +52,6 @@ export const generateErrorTextUtil = ({
8952
);
9053
};
9154

92-
const validate = (password: string, config: UsePasswordComplexityConfig): ComplexityErrors => {
93-
const { max_length, min_length, require_special_char, require_lowercase, require_numbers, require_uppercase } =
94-
config;
95-
const testComplexityCases = createTestComplexityCases(config);
96-
const testCases = testComplexityCases(password, {
97-
maxLength: config.max_length,
98-
minLength: config.min_length,
99-
});
100-
101-
const keys = {
102-
max_length,
103-
min_length,
104-
require_special_char,
105-
require_numbers,
106-
require_lowercase,
107-
require_uppercase,
108-
};
109-
110-
const _validationsFailedMap = new Map();
111-
for (const k in keys) {
112-
const key = k as keyof typeof keys;
113-
114-
if (!keys[key]) {
115-
continue;
116-
}
117-
118-
if (!testCases[key]) {
119-
_validationsFailedMap.set(key, true);
120-
}
121-
}
122-
123-
return Object.freeze(Object.fromEntries(_validationsFailedMap));
124-
};
125-
126-
export const createValidateComplexity = (config: UsePasswordComplexityConfig) => {
127-
return (password: string) => validate(password, config);
128-
};
129-
13055
export const usePasswordComplexity = (config: UsePasswordComplexityConfig) => {
13156
const [password, _setPassword] = useState('');
13257
const [failedValidations, setFailedValidations] = useState<ComplexityErrors>();

packages/clerk-js/src/ui/utils/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,4 @@ export * from './roleLocalizationKey';
1919
export * from './getRelativeToNowDateKey';
2020
export * from './mergeRefs';
2121
export * from './createSlug';
22-
export * from './zxcvbn';
2322
export * from './passwordUtils';
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { PasswordSettingsData } from '@clerk/types';
2+
3+
export type ComplexityErrors = {
4+
[key in keyof Partial<Omit<PasswordSettingsData, 'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn'>>]?: boolean;
5+
};
6+
7+
export type UsePasswordComplexityConfig = Omit<
8+
PasswordSettingsData,
9+
'disable_hibp' | 'min_zxcvbn_strength' | 'show_zxcvbn'
10+
>;
11+
12+
const createTestComplexityCases = (config: Pick<UsePasswordComplexityConfig, 'allowed_special_characters'>) => {
13+
let specialCharsRegex: RegExp;
14+
if (config.allowed_special_characters) {
15+
// Avoid a nested group by escaping the `[]` characters
16+
let escaped = config.allowed_special_characters.replace('[', '\\[');
17+
escaped = escaped.replace(']', '\\]');
18+
specialCharsRegex = new RegExp(`[${escaped}]`);
19+
} else {
20+
specialCharsRegex = /[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/;
21+
}
22+
23+
return (
24+
password: string,
25+
{
26+
minLength,
27+
maxLength,
28+
}: {
29+
minLength: number;
30+
maxLength: number;
31+
},
32+
) => {
33+
return {
34+
max_length: password.length < maxLength,
35+
min_length: password.length >= minLength,
36+
require_numbers: /\d/.test(password),
37+
require_lowercase: /[a-z]/.test(password),
38+
require_uppercase: /[A-Z]/.test(password),
39+
require_special_char: specialCharsRegex.test(password),
40+
};
41+
};
42+
};
43+
44+
export const validate = (password: string, config: UsePasswordComplexityConfig): ComplexityErrors => {
45+
const { max_length, min_length, require_special_char, require_lowercase, require_numbers, require_uppercase } =
46+
config;
47+
const testComplexityCases = createTestComplexityCases(config);
48+
const testCases = testComplexityCases(password, {
49+
maxLength: config.max_length,
50+
minLength: config.min_length,
51+
});
52+
53+
const keys = {
54+
max_length,
55+
min_length,
56+
require_special_char,
57+
require_lowercase,
58+
require_numbers,
59+
require_uppercase,
60+
};
61+
62+
const _validationsFailedMap = new Map();
63+
for (const k in keys) {
64+
const key = k as keyof typeof keys;
65+
66+
if (!keys[key]) {
67+
continue;
68+
}
69+
70+
if (!testCases[key]) {
71+
_validationsFailedMap.set(key, true);
72+
}
73+
}
74+
75+
return Object.freeze(Object.fromEntries(_validationsFailedMap));
76+
};
77+
78+
export const createValidateComplexity = (config: UsePasswordComplexityConfig) => {
79+
return (password: string) => validate(password, config);
80+
};

0 commit comments

Comments
 (0)