task(settings): Finish model / validation pattern

Because:
- We want to use class-validator for more standardized validation support
- We want to the link validator to leverage model validation

This Commit:
- Applies class-validator to all models
- Makes minor tweaks to the bind-decorator to support class-validator
- Makes minor tweaks to model-data-store to support class-validator
- Adds DIP to
- Ensures undefined is returned for unspecified values
- Cleans up link validator to use model.validate().
- Removes direct access to underlying model data used by ModelDataProvider
- Adds dirty flag to ModelDataProvider to reduce unnecessary validations
This commit is contained in:
dschom 2023-08-15 13:59:47 -07:00
Родитель e61b71909a
Коммит eb2c1bbc55
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: F26AEE99174EE68B
40 изменённых файлов: 647 добавлений и 603 удалений

Просмотреть файл

@ -79,6 +79,7 @@
"@types/react-webcam": "^3.0.0",
"base32-decode": "^1.0.0",
"base32-encode": "^1.2.0",
"class-validator": "^0.14.0",
"classnames": "^2.3.1",
"file-saver": "^2.0.5",
"fxa-auth-client": "workspace:*",

Просмотреть файл

@ -187,16 +187,16 @@ const AuthAndAccountSetupRoutes = (_: RouteComponentProps) => {
path="/account_recovery_confirm_key/*"
linkType={LinkType['reset-password']}
viewName="account-recovery-confirm-key"
getParamsFromModel={() => {
createLinkModel={() => {
return CreateCompleteResetPasswordLink();
}}
{...{ integration }}
>
{({ setLinkStatus, params }) => (
{({ setLinkStatus, linkModel }) => (
<AccountRecoveryConfirmKey
{...{
setLinkStatus,
params,
linkModel,
}}
/>
)}

Просмотреть файл

@ -50,10 +50,7 @@ const mockedProps: LinkExpiredProps = {
export const Default = () => <LinkExpired {...mockedProps} />;
export const LinkExpiredForResetPassword = () => (
<LinkExpiredResetPassword
{...{ email, viewName }}
integration={{ type: IntegrationType.Web }}
/>
<LinkExpiredResetPassword {...{ email, viewName }} />
);
export const LinkExpiredForSignin = () => (

Просмотреть файл

@ -40,12 +40,7 @@ const renderWithHistory = (ui: any, queryParams = '', account?: Account) => {
};
describe('LinkExpiredResetPassword', () => {
const component = (
<LinkExpiredResetPassword
{...{ email, viewName }}
integration={createMockWebIntegration()}
/>
);
const component = <LinkExpiredResetPassword {...{ email, viewName }} />;
afterEach(() => {
jest.clearAllMocks();

Просмотреть файл

@ -3,38 +3,24 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useState } from 'react';
import {
IntegrationType,
OAuthIntegration,
isOAuthIntegration,
useAccount,
} from '../../models';
import { useAccount } from '../../models';
import { ResendStatus } from '../../lib/types';
import { logViewEvent } from 'fxa-settings/src/lib/metrics';
import { REACT_ENTRYPOINT } from 'fxa-settings/src/constants';
import { LinkExpired } from '../LinkExpired';
import { IntegrationSubsetType } from '../../lib/integrations';
type LinkExpiredResetPasswordProps = {
email: string;
viewName: string;
integration: LinkExpiredResetPasswordIntegration;
email: string;
service?: string;
redirectUri?: string;
};
interface LinkExpiredResetPasswordOAuthIntegration {
type: IntegrationType.OAuth;
getService: () => ReturnType<OAuthIntegration['getService']>;
getRedirectUri: () => ReturnType<OAuthIntegration['getService']>;
}
type LinkExpiredResetPasswordIntegration =
| LinkExpiredResetPasswordOAuthIntegration
| IntegrationSubsetType;
export const LinkExpiredResetPassword = ({
email,
viewName,
integration,
email,
service,
redirectUri,
}: LinkExpiredResetPasswordProps) => {
// TODO in FXA-7630 add metrics event and associated tests for users hitting the LinkExpired page
const account = useAccount();
@ -45,12 +31,8 @@ export const LinkExpiredResetPassword = ({
const resendResetPasswordLink = async () => {
try {
if (isOAuthIntegration(integration)) {
await account.resetPassword(
email,
integration.getService(),
integration.getRedirectUri()
);
if (service && redirectUri) {
await account.resetPassword(email, service, redirectUri);
} else {
await account.resetPassword(email);
}

Просмотреть файл

@ -9,28 +9,36 @@ import { LinkStatus, LinkType } from '../../lib/types';
import { ResetPasswordLinkDamaged, SigninLinkDamaged } from '../LinkDamaged';
import { LinkExpiredResetPassword } from '../LinkExpiredResetPassword';
import { LinkExpiredSignin } from '../LinkExpiredSignin';
import { ModelDataProvider } from '../../lib/model-data';
import { Integration } from '../../models';
import { IntegrationType, isOAuthIntegration } from '../../models';
interface LinkValidatorChildrenProps<T> {
interface LinkValidatorChildrenProps<TModel> {
setLinkStatus: React.Dispatch<React.SetStateAction<LinkStatus>>;
params: T;
linkModel: TModel;
}
interface LinkValidatorProps<T> {
interface LinkValidatorIntegration {
type: IntegrationType;
}
interface LinkValidatorProps<TModel> {
linkType: LinkType;
viewName: string;
getParamsFromModel: () => T;
integration: Integration;
children: (props: LinkValidatorChildrenProps<T>) => React.ReactNode;
createLinkModel: () => TModel;
integration: LinkValidatorIntegration;
children: (props: LinkValidatorChildrenProps<TModel>) => React.ReactNode;
}
const LinkValidator = <TModel extends ModelDataProvider>({
children,
interface LinkModel {
isValid(): boolean;
email: string | undefined;
}
const LinkValidator = <TModel extends LinkModel>({
linkType,
viewName,
getParamsFromModel,
integration,
createLinkModel,
children,
}: LinkValidatorProps<TModel> & RouteComponentProps) => {
// If `LinkValidator` is a route component receiving `path, then `children`
// is a React.ReactElement
@ -38,18 +46,9 @@ const LinkValidator = <TModel extends ModelDataProvider>({
? (children as React.ReactElement).props.children
: children;
const params = getParamsFromModel();
const isValid = params.isValid();
const email = getEmailFromParams();
function getEmailFromParams() {
const email = params.getModelData().get('email');
if (typeof email === 'string') {
return email;
} else {
return undefined;
}
}
const linkModel = createLinkModel();
const isValid = linkModel.isValid();
const email = linkModel.email;
const [linkStatus, setLinkStatus] = useState<LinkStatus>(
isValid ? LinkStatus.valid : LinkStatus.damaged
@ -71,7 +70,18 @@ const LinkValidator = <TModel extends ModelDataProvider>({
linkType === LinkType['reset-password'] &&
email !== undefined
) {
return <LinkExpiredResetPassword {...{ email, viewName, integration }} />;
if (isOAuthIntegration(integration)) {
const service = integration.getService();
const redirectUri = integration.getRedirectUri();
return (
<LinkExpiredResetPassword
{...{ viewName, email, service, redirectUri }}
/>
);
}
return <LinkExpiredResetPassword {...{ viewName, email }} />;
}
if (
@ -82,7 +92,7 @@ const LinkValidator = <TModel extends ModelDataProvider>({
return <LinkExpiredSignin {...{ email, viewName }} />;
}
return <>{child({ setLinkStatus, params })}</>;
return <>{child({ setLinkStatus, linkModel })}</>;
};
export default LinkValidator;

Просмотреть файл

@ -65,6 +65,12 @@ describe('lib/integrations/integration-factory', () => {
.stub(flags, 'isV3DesktopContext')
.returns(!!flagOverrides.isV3DesktopContext);
urlQueryData.set('scope', 'profile');
urlQueryData.set('client_id', '123');
urlQueryData.set('redirect_uri', 'https://redirect.to');
urlHashData.set('scope', 'profile');
// Create a factory with current state
const factory = new IntegrationFactory({
window,
@ -75,9 +81,6 @@ describe('lib/integrations/integration-factory', () => {
delegates,
});
urlQueryData.set('client_id', '123');
urlQueryData.set('redirect_uri', 'https://redirect.to');
// Create the integration
const integration = factory.getIntegration();
checkInstance(integration);

Просмотреть файл

@ -183,8 +183,8 @@ export class IntegrationFactory {
// Important!
// FxDesktop declares both `entryPoint` (capital P) and
// `entrypoint` (lowcase p). Normalize to `entrypoint`.
const entryPoint = integration.data.getModelData().get('entryPoint');
const entrypoint = integration.data.getModelData().get('entrypoint');
const entryPoint = integration.data.getModelData('entryPoint');
const entrypoint = integration.data.getModelData('entrypoint');
if (
entryPoint != null &&
entrypoint != null &&

Просмотреть файл

@ -2,29 +2,28 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
bind,
KeyTransforms as T,
getBoundKeys,
validateData,
} from './bind-decorator';
import { ModelValidation as V } from './model-validation';
import { bind, KeyTransforms as T, getBoundKeys } from './bind-decorator';
import { GenericData } from './data-stores';
import { ModelDataProvider } from './model-data-provider';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
/**
* Example model for testing bind decorators
*/
class TestModel extends ModelDataProvider {
@bind([], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
testField: string | undefined;
@bind([V.isNonEmptyString], T.snakeCase)
@IsOptional()
@IsString()
@IsNotEmpty()
@bind(T.snakeCase)
testValidatedField: string | undefined;
}
describe('bind-decorator', function () {
it('creates with empty state', () => {
const data = new GenericData({});
const model1 = new TestModel(data);
@ -99,19 +98,55 @@ describe('bind-decorator', function () {
expect(T.snakeCase('')).toEqual('');
});
it('validates', () => {
const data = new GenericData({});
const model1 = new TestModel(data);
expect(() => {
validateData(model1);
}).toThrow();
});
it('gets bound keys', () => {
const data = new GenericData({});
const model1 = new TestModel(data);
expect(getBoundKeys(model1)).toEqual(['testField', 'testValidatedField']);
});
it('gets data directly', () => {
const data = new GenericData({ test_field: 'foo' });
const model1 = new TestModel(data);
expect(model1.getModelData('test_field')).toEqual('foo');
});
it('sets data directly', () => {
const data = new GenericData({ test_field: 'foo' });
const model1 = new TestModel(data);
model1.setModelData('test_field', 'bar');
expect(model1.getModelData('test_field')).toEqual('bar');
});
it('returns undefined for unset data marked as optional', () => {
const data = new GenericData({});
const model1 = new TestModel(data);
expect(model1.getModelData('test_field') === undefined).toBeTruthy();
expect(model1.getModelData('test_field') !== null).toBeTruthy();
});
it('will not set invalid data directly', () => {
const data = new GenericData({});
const model1 = new TestModel(data);
expect(() => model1.setModelData('test_field', 0)).toThrow();
});
it('will not get invalid data directly', () => {
const data = new GenericData({ test_validated_field: '' });
const model1 = new TestModel(data);
expect(() => model1.getModelData('testValidatedField')).toThrow();
});
it('sets invalid when validate is specified as false', () => {
const data = new GenericData({ test_field: 'foo' });
const model1 = new TestModel(data);
expect(() => model1.setModelData('test_field', 0, false)).not.toThrow();
});
it('gets invalid when validate is specified as false', () => {
const data = new GenericData({ test_field: 0 });
const model1 = new TestModel(data);
expect(() => model1.getModelData('test_field', false)).not.toThrow();
});
});

Просмотреть файл

@ -2,11 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import 'reflect-metadata';
import {
ModelValidationError,
ModelValidationErrors,
} from './model-validation';
import { ModelDataProvider } from './model-data-provider';
import { ModelDataProvider, isModelDataProvider } from './model-data-provider';
/**
* Turns a field name into a lookup key. This can be one of three states.
@ -51,25 +47,6 @@ const getKey = (keyTransform: KeyTransform, defaultValue: string) => {
*/
export const bindMetadataKey = Symbol('bind');
/**
* For a given object that is bound to a data store, rerun all the
* validation checks on values that bind model fields to the data values held
* in the data store.
* @param target - A model that is bound to a data store. The ModelDataProvider is
* the base class, and provides access to the underlying data store.
*/
export function validateData(target: ModelDataProvider) {
for (const key of Object.keys(Object.getPrototypeOf(target))) {
// Resolves the @bind decorator
const bind = Reflect.getMetadata(bindMetadataKey, target, key);
// If the @bind decorator was present, run its validation checks
if (bind?.validate && bind?.key) {
bind.validate(bind.key, target.getModelData().get(bind.key));
}
}
}
/**
* Looks up a list of property names that have bindings on an object
* @param target
@ -86,24 +63,6 @@ export function getBoundKeys(target: ModelDataProvider) {
return result;
}
/**
* Provides the data store for the model, and runs a couple sanity checks on the model state.
*/
function getModelData(model: ModelDataProvider | any) {
if (!(model instanceof ModelDataProvider)) {
throw new Error(
'Invalid bind! Does the model is not an an instance of ModelDataProvider. Check that the model inherits from ModelDataProvider.'
);
}
const data = model.getModelData();
if (data == null) {
throw new Error(
'Invalid bind! Has the data store for the model been initialized?'
);
}
return data;
}
/**
* Represents a validation check
**/
@ -111,18 +70,17 @@ export type ValidationCheck = <T>(key: string, value: T) => void;
/**
* A type script decorator for binding to an underlying data store.
* @param checks A list of sequential check to validate the state of the underlying value.
* @param dataKey A key in the underlying data store. Used to bind the field to a key value pair in the data store.
* @returns A value of type T
* @example
*
* class User {
* @bind([V.isString], 'first_name')
* @bind('first_name')
* firstName
* }
*
*/
export const bind = <T>(checks: ValidationCheck[], dataKey?: KeyTransform) => {
export const bind = <T>(dataKey?: KeyTransform) => {
// The actual decorator implementation. Note we have to use 'any' as a
// return type here because that's the type expected by the TS decorator
// definition.
@ -135,38 +93,8 @@ export const bind = <T>(checks: ValidationCheck[], dataKey?: KeyTransform) => {
throw new Error('Invalid bind! Model inherit from ModelDataProvider!');
}
/**
* Sequentially executes validation checks.
*/
function validate<T>(key: string, value: unknown): T {
const errors = new Array<ModelValidationError>();
checks.forEach((check) => {
try {
// Throws an error if value is bad.
value = check(key, value);
} catch (err) {
if (err instanceof ModelValidationError) {
errors.push(err);
} else {
throw err;
}
}
});
if (errors.length > 0) {
throw new ModelValidationErrors(
`Errors in current data store: \n${errors
.map((x) => x.toString())
.join(';')}`,
errors
);
}
return value as T;
}
const metadata = {
memberName,
validate,
key: getKey(dataKey, memberName),
};
Reflect.defineMetadata(bindMetadataKey, metadata, model, memberName);
@ -176,24 +104,34 @@ export const bind = <T>(checks: ValidationCheck[], dataKey?: KeyTransform) => {
const property = {
enumerable: true,
set: function (value: T) {
const data = getModelData(this);
const key = getKey(dataKey, memberName);
const currentValue = data.get(key);
// Don't bother setting state needlessly. Depending on the data
// store writes may or may not be cheap.
if (currentValue !== value) {
data.set(key, validate(key, value));
if (!isModelDataProvider(this)) {
throw new InvalidModelInstance();
}
const key = getKey(dataKey, memberName);
this.setModelData(key, value);
},
get: function () {
const data = getModelData(this);
if (!isModelDataProvider(this)) {
throw new InvalidModelInstance();
}
const key = getKey(dataKey, memberName);
const value = data.get(key);
return validate(key, value);
return this.getModelData(key);
},
};
Object.defineProperty(model, memberName, property);
return property;
};
};
/**
* Error for situations where the model data provider object that backs the @bind is invalid.
*/
export class InvalidModelInstance extends Error {
constructor() {
super(
'Invalid bind! Does the model is not an an instance of ModelDataProvider. Check that the model inherits from ModelDataProvider.'
);
}
}

Просмотреть файл

@ -3,7 +3,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export * from './bind-decorator';
export * from './model-validation';
export * from './data-stores';
export * from './model-data-provider';
export * from './model-data-store';

Просмотреть файл

@ -12,11 +12,6 @@ describe('model-data-provider', function () {
modelDataProvider = new ModelDataProvider(new GenericData({}));
});
it('gets model data accessor', () => {
const dataAccessor = modelDataProvider.getModelData();
expect(dataAccessor).toBeDefined();
});
it('validates', () => {
expect(() => modelDataProvider.validate()).not.toThrow();
});

Просмотреть файл

@ -2,11 +2,24 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { validateData } from './bind-decorator';
import { ModelValidationErrors } from './model-validation';
import { validateSync, ValidationError } from 'class-validator';
import { ModelDataStore } from './model-data-store';
/**
* Type guard for validating model
* @param model
* @returns
*/
export function isModelDataProvider(model: any): model is ModelDataProvider {
if (model instanceof ModelDataProvider) {
return true;
}
return false;
}
export class ModelDataProvider {
private isDirty = true;
constructor(protected readonly modelData: ModelDataStore) {
if (modelData == null) {
throw new Error('dataAccessor must be provided!');
@ -15,10 +28,43 @@ export class ModelDataProvider {
/**
* Gets the data collection that holds the state of the model.
* @param key Key data is held under
* @param value Value for key
* @param validate Whether or not to validate. Optional and defaults to true.
* @returns underlying data
*/
setModelData(key: string, value: unknown, validate = true) {
if (this.modelData == null) {
throw new Error(
'Invalid bind! Has the data store for the model been initialized?'
);
}
const currentValue = this.modelData.get(key);
if (currentValue !== value) {
this.modelData.set(key, value);
this.isDirty = true;
if (validate) {
this.validate();
}
}
}
/**
* Fetches data from underlying data store
* @param key The key to look up
* @param validate Whether or not to validate. Optional and defaults to true.
* @returns
*/
getModelData() {
return this.modelData;
getModelData(key: string, validate = true) {
if (this.modelData == null) {
throw new Error(
'Invalid bind! Has the data store for the model been initialized?'
);
}
if (validate) {
this.validate();
}
return this.modelData.get(key);
}
/**
@ -33,10 +79,25 @@ export class ModelDataProvider {
/**
* Checks the state of the model data is valid. Throws an error if not valid.
* @param key A specific property name to validate.
* @returns
*/
validate() {
return validateData(this);
validate(property?: string) {
if (!this.isDirty) {
return;
}
this.isDirty = false;
let errors = validateSync(this);
// If a key was provided only consider errors for that property.
if (property) {
errors = errors.filter((x) => x.property === property);
}
if (errors.length > 0) {
throw new ModelValidationErrors(errors);
}
}
/**
@ -49,7 +110,9 @@ export class ModelDataProvider {
return true;
} catch (err) {
if (err instanceof ModelValidationErrors) {
console.warn(err.errors.map((x) => `${x.key}-${x.value}-${x.message}`));
err.errors.forEach((x) => {
console.warn(x.toString());
});
return false;
}
throw err;
@ -63,16 +126,16 @@ export class ModelDataProvider {
*/
tryValidate(): {
isValid: boolean;
error?: ModelValidationErrors;
error?: ValidationError;
} {
let error: ModelValidationErrors | undefined;
let error: ValidationError | undefined;
let isValid = true;
try {
this.validate();
} catch (err) {
console.warn(err);
isValid = false;
if (err instanceof ModelValidationErrors) {
if (err instanceof ValidationError) {
error = err;
} else {
throw err;
@ -85,3 +148,12 @@ export class ModelDataProvider {
};
}
}
export class ModelValidationErrors extends Error {
constructor(public readonly errors: ValidationError[]) {
super(
'Model Validation Errors Encountered! Fields:' +
errors.map((x) => x.toString()).join(',')
);
}
}

Просмотреть файл

@ -1,49 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { ModelValidation as V } from './model-validation';
describe('model-validation', function () {
//let sandbox:SinonSandbox;
it('validates string', () => {
V.isString('', '');
V.isString('', null);
V.isString('', undefined);
expect(() => V.isString('', {})).toThrow();
expect(() => V.isString('', 1)).toThrow();
expect(() => V.isString('', true)).toThrow();
expect(() => V.isString('', false)).toThrow();
});
it('validates hex string', () => {
// Based on string, so only check formatting here
V.isHex('', '123ABC');
expect(() => V.isHex('', 'zys')).toThrow();
});
it('validates non empty string', () => {
V.isNonEmptyString('', '1');
expect(() => V.isNonEmptyString('', '')).toThrow();
});
it('validates number', () => {
V.isNumber('', 1);
V.isNumber('', 1.1);
V.isNumber('', 1e2);
V.isNumber('', -1e2);
V.isNumber('', '1');
V.isNumber('', '1.1');
V.isNumber('', '1e2');
V.isNumber('', '-1e2');
V.isNumber('', null);
V.isNumber('', undefined);
expect(() => V.isNumber('', 'a')).toThrow();
expect(() => V.isNumber('', true)).toThrow();
expect(() => V.isNumber('', false)).toThrow();
expect(() => V.isNumber('', {})).toThrow();
});
// TODO: Complete validations and tests
});

Просмотреть файл

@ -1,199 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// TODO: Figure out how to port Vat. Here's a simplistic implementation for POC.
import { isEmailValid } from 'fxa-shared/email/helpers';
import { Constants } from '../constants';
/**
* Dedicated error class for validation errors that occur when using the @bind decorator.
*/
export class ModelValidationError extends Error {
constructor(
public readonly key: string,
public readonly value: any,
public readonly message: string
) {
super(message);
}
toString() {
return `[key=${this.key}] [value=${this.value}] - ${this.message} `;
}
}
export class ModelValidationErrors extends Error {
constructor(
public readonly message: string,
public readonly errors: ModelValidationError[]
) {
super(message);
}
}
/** Validations */
export const ModelValidation = {
isRequired: (k: string, v: any) => {
if (v == null) {
throw new ModelValidationError(k, v, 'Must exist!');
}
return v;
},
isString: (k: string, v: any) => {
if (v == null) {
return v;
}
if (typeof v !== 'string') {
throw new ModelValidationError(k, v, 'Is not string');
}
return v;
},
isHex: (k: string, v: any) => {
if (v == null) {
return v;
}
if (typeof v !== 'string' || !/^(?:[a-fA-F0-9]{2})+$/.test(v)) {
throw new ModelValidationError(k, v, 'Is not a hex string');
}
return v;
},
isBoolean: (k: string, v: any) => {
if (v == null) {
return v;
}
if (typeof v === 'boolean') {
return v;
}
if (typeof v === 'string') {
v = v.toLocaleLowerCase().trim();
}
if (v === 'true') {
return true;
}
if (v === 'false') {
return false;
}
throw new ModelValidationError(k, v, 'Is not boolean');
},
isNumber: (k: string, v: any) => {
if (v == null) {
return v;
}
const n = parseFloat(v);
if (isNaN(n)) {
throw new ModelValidationError(k, v, 'Is not a number');
}
return n;
},
isClientId: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isAccessType: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isCodeChallenge: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isCodeChallengeMethod: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isPrompt: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isUrl: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isUri: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isNonEmptyString: (k: string, v: any) => {
v = ModelValidation.isString(k, v);
if ((v || '').length === 0) {
throw new ModelValidationError(k, v, 'Cannot be an empty string');
}
return v;
},
isVerificationCode: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isAction: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isKeysJwk: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isIdToken: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isEmail: (k: string, v: any) => {
// TODO: Add validation
v = ModelValidation.isString(k, v);
if (!isEmailValid(v)) {
throw new ModelValidationError(k, v, 'Is not a valid email');
}
return v;
},
isGreaterThanZero: (k: string, v: any) => {
// TODO: Add validation
v = ModelValidation.isNumber(k, v);
if (v < 0) {
throw new ModelValidationError(k, v, 'Is not a positive number');
}
return v;
},
isPairingAuthorityRedirectUri: (k: string, v: any) => {
if ((v || '') !== Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI) {
throw new ModelValidationError(
k,
v,
'Is not a DEVICE_PAIRING_AUTHORITY_REDIRECT_URI'
);
}
return v;
},
isChannelId: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isChannelKey: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
isValidCountry: (k: string, v: any) => {
// TODO: Add validation
return ModelValidation.isString(k, v);
},
};

Просмотреть файл

@ -10,7 +10,10 @@ describe('models/integrations/channel-info', function () {
let model: ChannelInfo;
beforeEach(function () {
data = new GenericData({});
data = new GenericData({
channel_id: Buffer.from('id123').toString('base64'),
channel_key: Buffer.from('key123').toString('base64'),
});
model = new ChannelInfo(data);
});
@ -19,11 +22,11 @@ describe('models/integrations/channel-info', function () {
});
it('binds model to data store', () => {
data.set('channel_id', 'foo');
data.set('channel_key', 'bar');
data.set('channel_id', Buffer.from('foo').toString('base64'));
data.set('channel_key', Buffer.from('bar').toString('base64'));
expect(model.channelId).toEqual('foo');
expect(model.channelKey).toEqual('bar');
expect(model.channelId).toEqual(Buffer.from('foo').toString('base64'));
expect(model.channelKey).toEqual(Buffer.from('bar').toString('base64'));
});
it('validates', () => {

Просмотреть файл

@ -6,13 +6,17 @@ import {
bind,
KeyTransforms as T,
ModelDataProvider,
ModelValidation as V,
} from '../../lib/model-data';
import { IsBase64, IsNotEmpty } from 'class-validator';
export class ChannelInfo extends ModelDataProvider {
@bind([V.isChannelId], T.snakeCase)
@IsBase64()
@IsNotEmpty()
@bind(T.snakeCase)
channelId: string | undefined;
@bind([V.isChannelKey], T.snakeCase)
@IsBase64()
@IsNotEmpty()
@bind(T.snakeCase)
channelKey: string | undefined;
}

Просмотреть файл

@ -2,26 +2,43 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
IsBoolean,
IsHexadecimal,
IsOptional,
IsString,
} from 'class-validator';
import {
bind,
KeyTransforms as T,
ModelDataProvider,
ModelValidation as V,
} from '../../lib/model-data';
export class ClientInfo extends ModelDataProvider {
@bind([V.isString, V.isHex], 'id')
@IsOptional()
@IsHexadecimal()
@bind('id')
clientId: string | undefined;
@bind([V.isString], T.snakeCase)
// TODO - Validation - Needs @IsEncodedUrl()
@IsOptional()
@IsString()
@bind(T.snakeCase)
imageUri: string | undefined;
@bind([V.isString, V.isRequired], 'name')
@IsOptional()
@IsString()
@bind('name')
serviceName: string | undefined;
@bind([V.isString], T.snakeCase)
// TODO - Validation - Needs @IsEncodedUrl()
@IsOptional()
@IsString()
@bind(T.snakeCase)
redirectUri: string | undefined;
@bind([V.isBoolean])
@IsOptional()
@IsBoolean()
@bind()
trusted: boolean | undefined;
}

Просмотреть файл

@ -11,8 +11,12 @@ describe('models/integrations/oauth-relier', function () {
let model: OAuthIntegration;
beforeEach(function () {
data = new GenericData({});
oauthData = new GenericData({});
data = new GenericData({
scope: 'profile',
});
oauthData = new GenericData({
scope: 'profile',
});
model = new OAuthIntegration(data, oauthData, {
scopedKeysEnabled: true,
scopedKeysValidation: {},
@ -61,8 +65,10 @@ describe('models/integrations/oauth-relier', function () {
}
it('empty scope', async () => {
const integration = getIntegration('');
await expect(integration.getPermissions()).rejects.toThrow();
await expect(async () => {
const integration = getIntegration('');
const _permissions = await integration.getPermissions();
}).rejects.toThrow();
});
it('whitespace scope', async () => {

Просмотреть файл

@ -9,16 +9,25 @@ import {
RelierAccount,
RelierClientInfo,
} from './base-integration';
import {
ModelDataStore,
bind,
KeyTransforms as T,
ModelValidation as V,
} from '../../lib/model-data';
import { ModelDataStore, bind, KeyTransforms as T } from '../../lib/model-data';
import { Constants } from '../../lib/constants';
import { ERRORS, OAuthError } from '../../lib/oauth';
import { IntegrationFlags } from '../../lib/integrations';
import { BaseIntegrationData } from './web-integration';
import {
IsBoolean,
IsEmail,
IsHexadecimal,
IsIn,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
const PKCE_CODE_CHALLENGE_LENGTH = 43;
interface OAuthIntegrationFeatures extends IntegrationFeatures {
webChannelSupport: boolean;
@ -45,64 +54,115 @@ export function isOAuthIntegration(integration: {
// TODO: probably move this somewhere else
export class OAuthIntegrationData extends BaseIntegrationData {
@bind([V.isString], T.snakeCase)
// TODO - Validation - Can we get a set of known client ids from config or api call? See https://github.com/mozilla/fxa/pull/15677#discussion_r1291534277
@IsOptional()
@IsHexadecimal()
@bind(T.snakeCase)
clientId: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
imageUri: string | undefined;
@bind([V.isBoolean])
@IsBoolean()
@IsOptional()
@bind()
trusted: boolean | undefined;
@bind([V.isAccessType], T.snakeCase)
@IsOptional()
@IsIn(['offline', 'online'])
@bind(T.snakeCase)
accessType: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
acrValues: string | undefined;
@bind([V.isAction])
// TODO - Validation - Double check actions
@IsOptional()
@IsIn(['signin', 'signup', 'email', 'force_auth', 'pairing'])
@bind()
action: string | undefined;
@bind([V.isCodeChallenge], T.snakeCase)
@IsOptional()
@IsString()
@MinLength(43)
@MaxLength(128)
@bind(T.snakeCase)
codeChallenge: string | undefined;
@bind([V.isCodeChallengeMethod])
@IsOptional()
@IsIn(['S256'])
@bind(T.snakeCase)
codeChallengeMethod: string | undefined;
@bind([V.isString], T.snakeCase)
// TODO - Validation - Should this be base64?
@IsOptional()
@IsString()
@bind(T.snakeCase)
keysJwk: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
idTokenHint: string | undefined;
@bind([V.isGreaterThanZero], T.snakeCase)
@IsPositive()
@IsOptional()
@bind(T.snakeCase)
maxAge: number | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
permissions: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
prompt: string | undefined;
@bind([V.isUrl])
// TODO - Validation - This should be a URL, but it is encoded and must be decoded in order to validate.
@IsOptional()
@IsString()
@bind()
redirectTo: string | undefined;
@bind([V.isUrl], T.snakeCase)
// TODO - Validation - This should be a URL, but it is encoded and must be decoded in order to validate.
@IsOptional()
@IsString()
@bind(T.snakeCase)
redirectUrl: string | undefined;
@bind([V.isString], T.snakeCase)
// TODO - Validation - Needs custom validation, see IsRedirectUriValid in content server.
// TODO - Validation - Seems to be required for OAuth
@IsString()
@IsOptional()
@bind(T.snakeCase)
redirectUri: string | undefined;
@bind([V.isString], T.snakeCase)
@IsBoolean()
@IsOptional()
@bind(T.snakeCase)
returnOnError: boolean | undefined;
@bind([V.isString])
// TODO - Validation - Should scope be required?
@IsOptional()
@IsString()
@IsNotEmpty()
@bind()
scope: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
state: string | undefined;
@bind([V.isString])
@IsOptional()
@IsEmail()
@bind()
loginHint: string | undefined;
}

Просмотреть файл

@ -11,7 +11,9 @@ describe('models/integrations/pairing-authority-relier', function () {
let model: PairingAuthorityIntegration;
beforeEach(function () {
data = new GenericData({});
data = new GenericData({
scope: 'profile',
});
storageData = new GenericData({});
model = new PairingAuthorityIntegration(data, storageData, {
scopedKeysEnabled: true,

Просмотреть файл

@ -9,14 +9,13 @@ import {
OAuthIntegrationData,
OAuthIntegrationOptions,
} from './oauth-integration';
import {
bind,
KeyTransforms as T,
ModelValidation as V,
} from '../../lib/model-data';
import { bind, KeyTransforms as T } from '../../lib/model-data';
import { IsBase64, IsNotEmpty } from 'class-validator';
export class PairingAuthorityIntegrationData extends OAuthIntegrationData {
@bind([V.isString], T.snakeCase)
@IsBase64()
@IsNotEmpty()
@bind(T.snakeCase)
channelId: string = '';
}

Просмотреть файл

@ -11,7 +11,7 @@ describe('models/integration/pairing-supplicant-integration', function () {
let model: PairingSupplicantIntegration;
beforeEach(function () {
data = new GenericData({});
data = new GenericData({ scope: 'profile' });
storageData = new GenericData({});
model = new PairingSupplicantIntegration(data, storageData, {
scopedKeysEnabled: true,

Просмотреть файл

@ -6,11 +6,16 @@ import { OAuthIntegrationData } from '.';
import { IntegrationType } from './base-integration';
import { bind } from '../../lib/model-data';
import { OAuthIntegration, OAuthIntegrationOptions } from './oauth-integration';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
// TODO in the 'Pairing' React epic. This shouldn't have any `feature` overrides but feel
// free to look at all of that logic with fresh eyes in case we want to do it differently.
export class PairingSupplicantIntegrationData extends OAuthIntegrationData {
@bind([])
// TODO - Validation - Should scope be required?
@IsOptional()
@IsString()
@IsNotEmpty()
@bind()
scope: string | undefined = '';
}

Просмотреть файл

@ -2,11 +2,22 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
IsBase64,
IsBoolean,
IsEmail,
IsHexadecimal,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
} from 'class-validator';
import {
bind,
KeyTransforms as T,
ModelDataProvider,
ModelValidation as V,
} from '../../lib/model-data';
// Sign inflow
@ -14,54 +25,93 @@ import {
// https://mozilla.github.io/ecosystem-platform/api#tag/OAuth-Server-API-Overview
export class SignInSignUpInfo extends ModelDataProvider {
@bind([V.isAccessType], T.snakeCase)
@IsOptional()
@IsIn(['offline', 'online'])
@bind(T.snakeCase)
accessType: string | undefined;
@bind([V.isString], T.snakeCase)
@IsString()
@IsOptional()
@bind(T.snakeCase)
acrValues: string | undefined;
@bind([V.isAction], T.snakeCase)
// TODO - Validation - Double check actions
@IsOptional()
@IsIn(['signin', 'signup', 'email', 'force_auth', 'pairing'])
@bind(T.snakeCase)
action: string | undefined;
@bind([V.isClientId], T.snakeCase)
@IsOptional()
@IsHexadecimal()
@bind(T.snakeCase)
clientId: string | undefined;
@bind([V.isCodeChallenge], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
codeChallenge: string | undefined;
@bind([V.isCodeChallengeMethod], T.snakeCase)
@IsOptional()
@IsIn(['S256'])
@bind(T.snakeCase)
codeChallengeMethod: string | undefined;
@bind([V.isKeysJwk], T.snakeCase)
@IsOptional()
@IsBase64()
@bind(T.snakeCase)
keysJwk: string | undefined;
@bind([V.isIdToken], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
idTokenHint: string | undefined;
@bind([V.isEmail], T.snakeCase)
@IsOptional()
@IsEmail()
@bind(T.snakeCase)
loginHint: string | undefined;
@bind([V.isGreaterThanZero], T.snakeCase)
@IsOptional()
@IsInt()
@IsPositive()
@bind(T.snakeCase)
maxAge: number | undefined;
@bind([V.isPrompt])
@IsOptional()
@IsIn(['consent', 'none', 'login'])
@bind()
prompt: string | undefined;
@bind([V.isPairingAuthorityRedirectUri], T.snakeCase)
// TODO - Validation - Needs @IsEncodedUrl()
@IsOptional()
@IsString()
@bind(T.snakeCase)
redirectUri: string | undefined;
@bind([V.isUrl], T.snakeCase)
// TODO - Validation - Needs @IsEncodedUrl()
@IsOptional()
@IsString()
@bind(T.snakeCase)
redirectTo: string | undefined;
@bind([V.isBoolean], T.snakeCase)
@IsOptional()
@IsBoolean()
@bind(T.snakeCase)
returnOnError: boolean | undefined;
@bind([V.isNonEmptyString])
@IsOptional()
@IsString()
@IsNotEmpty()
@bind()
scope: string | undefined;
@bind([V.isNonEmptyString])
@IsOptional()
@IsString()
@bind()
state: string | undefined;
@bind([V.isEmail])
@IsOptional()
@IsEmail()
@bind()
email: string | undefined;
}

Просмотреть файл

@ -2,29 +2,52 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
IsHexadecimal,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import {
bind,
KeyTransforms as T,
ModelDataProvider,
ModelValidation as V,
} from '../../lib/model-data';
export class SupplicantInfo extends ModelDataProvider {
@bind([V.isAccessType], T.snakeCase)
@IsOptional()
@IsIn(['offline', 'online'])
@bind(T.snakeCase)
accessType: string | undefined;
@bind([V.isClientId], T.snakeCase)
@IsOptional()
@IsHexadecimal()
@bind(T.snakeCase)
clientId: string | undefined;
@bind([V.isCodeChallenge], T.snakeCase)
@IsOptional()
@IsString()
@MinLength(43)
@MaxLength(128)
@bind(T.snakeCase)
codeChallenge: string | undefined;
@bind([V.isCodeChallengeMethod], T.snakeCase)
@IsOptional()
@IsIn(['S256'])
@bind(T.snakeCase)
codeChallengeMethod: string | undefined;
@bind([V.isNonEmptyString])
@IsOptional()
@IsString()
@IsNotEmpty()
@bind()
scope: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind()
state: string | undefined;
}

Просмотреть файл

@ -7,31 +7,54 @@ import {
IntegrationFeatures,
IntegrationType,
} from './base-integration';
import {
bind,
ModelValidation as V,
ModelDataStore,
} from '../../lib/model-data';
import { bind, ModelDataStore } from '../../lib/model-data';
import { Constants } from '../../lib/constants';
import { BaseIntegrationData } from './web-integration';
import {
IsBase64,
IsISO31661Alpha3,
IsIn,
IsOptional,
IsString,
Length,
MaxLength,
MinLength,
} from 'class-validator';
export class SyncBasicIntegrationData extends BaseIntegrationData {
@bind([V.isValidCountry])
// TODO - Validation - Will @IsISO31661Alpha2() work?
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(7)
@bind()
country: string | undefined;
@bind([V.isString])
@IsOptional()
@IsBase64()
@Length(8)
@bind()
signinCode: string | undefined;
@bind([V.isAction])
// TODO - Validation - Double check actions
@IsOptional()
@IsIn(['signin', 'signup', 'email', 'force_auth', 'pairing'])
@bind()
action: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
syncPreference: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
multiService: boolean | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
tokenCode: string | undefined;
}

Просмотреть файл

@ -7,58 +7,98 @@ import {
bind,
KeyTransforms as T,
ModelDataProvider,
ModelValidation as V,
ModelDataStore,
} from '../../lib/model-data';
import {
IsEmail,
IsHexadecimal,
IsIn,
IsOptional,
IsString,
Length,
} from 'class-validator';
// TODO: move this to other file, FXA-8099
export class BaseIntegrationData extends ModelDataProvider {
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
context: string | undefined;
@bind([V.isString])
@IsOptional()
@IsEmail()
@bind()
email: string | undefined;
@bind([V.isString])
@IsOptional()
@IsEmail()
@bind()
emailToHashWith: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
entrypoint: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
entrypointExperiment: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
entrypointVariation: string | undefined;
@bind([V.isBoolean], T.snakeCase)
@IsOptional()
@IsIn(['true', 'false', true, false])
@bind(T.snakeCase)
resetPasswordConfirm: boolean | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
setting: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
service: string | undefined;
@bind([V.isString])
@IsOptional()
@IsString()
@bind()
style: string | undefined;
@bind([V.isString])
@IsOptional()
@IsHexadecimal()
@Length(32)
@bind()
uid: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
utmCampaign: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
utmContent: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
utmMedium: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
utmSource: string | undefined;
@bind([V.isString], T.snakeCase)
@IsOptional()
@IsString()
@bind(T.snakeCase)
utmTerm: string | undefined;
}

Просмотреть файл

@ -99,7 +99,7 @@ export function mockStorage() {
};
}
export function mockUrlQueryData(params: Record<string, string>) {
export function mockUrlQueryData(params: Record<string, unknown>) {
const window = new ReachRouterWindow();
const data = new UrlQueryData(window);
for (const param of Object.keys(params)) {

Просмотреть файл

@ -3,27 +3,35 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
bind,
ModelDataProvider,
ModelValidation as V,
} from '../../../lib/model-data';
IsEmail,
IsHexadecimal,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { bind, ModelDataProvider } from '../../../lib/model-data';
export class CompleteResetPasswordLink extends ModelDataProvider {
// TODO: change `isNonEmptyString` to `email` when validation is properly set up.
// This is temporary for tests/Storybook so that `email=''` shows a damaged link
@bind([V.isNonEmptyString, V.isRequired])
@IsOptional()
@IsEmail()
@bind()
email: string = '';
// TODO: add @bind `isEmail` when validation is properly set up.
// This should be _optional_ but when this exists it should be an email.
emailToHashWith: string = '';
@IsOptional()
@IsEmail()
@bind()
emailToHashWith: string | undefined = '';
@bind([V.isNonEmptyString, V.isRequired])
@IsString()
@IsNotEmpty()
@bind()
code: string = '';
@bind([V.isHex, V.isRequired])
@IsHexadecimal()
@bind()
token: string = '';
@bind([V.isHex, V.isRequired])
@IsHexadecimal()
@bind()
uid: string = '';
}

Просмотреть файл

@ -2,28 +2,41 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { ModelValidation, ModelDataProvider, bind } from '../../lib/model-data';
import {
IsEmail,
IsHexadecimal,
IsNotEmpty,
IsOptional,
IsString,
Length,
} from 'class-validator';
import { ModelDataProvider, bind } from '../../lib/model-data';
export * from './verification-info';
export type VerificationInfoLinkStatus = 'expired' | 'damaged' | 'valid';
const { isEmail, isRequired, isVerificationCode, isHex, isString, isBoolean } =
ModelValidation;
export class VerificationInfo extends ModelDataProvider {
@bind([isRequired, isEmail])
@IsEmail()
@bind()
email: string = '';
@bind([isRequired, isEmail])
emailToHashWith: string = '';
@IsOptional()
@IsEmail()
@bind()
emailToHashWith: string | undefined = '';
@bind([isRequired, isVerificationCode])
@IsHexadecimal()
@Length(32)
@bind()
code: string = '';
@bind([isRequired, isHex])
@IsHexadecimal()
@bind()
token: string = '';
@bind([isRequired, isString])
@IsHexadecimal()
@Length(32)
@bind()
uid: string = '';
}

Просмотреть файл

@ -41,10 +41,10 @@ type SubmitData = {
export const viewName = 'account-recovery-confirm-key';
const AccountRecoveryConfirmKey = ({
params,
linkModel,
setLinkStatus,
}: {
params: CompleteResetPasswordLink;
linkModel: CompleteResetPasswordLink;
setLinkStatus: React.Dispatch<React.SetStateAction<LinkStatus>>;
}) => {
// TODO: grab serviceName from the relier
@ -81,8 +81,8 @@ const AccountRecoveryConfirmKey = ({
}
};
checkPasswordForgotToken(params.token);
}, [account, params.token, setLinkStatus]);
checkPasswordForgotToken(linkModel.token);
}, [account, linkModel.token, setLinkStatus]);
const { handleSubmit, register } = useForm<FormData>({
mode: 'onBlur',
@ -246,10 +246,10 @@ const AccountRecoveryConfirmKey = ({
const recoveryKeyStripped = recoveryKey.replace(/\s/g, '');
onSubmit({
recoveryKey: recoveryKeyStripped,
token: params.token,
code: params.code,
email: params.email,
uid: params.uid,
token: linkModel.token,
code: linkModel.code,
email: linkModel.email,
uid: linkModel.uid,
});
})}
data-testid="account-recovery-confirm-key-form"

Просмотреть файл

@ -77,14 +77,14 @@ export const getSubject = (
<LinkValidator
linkType={LinkType['reset-password']}
viewName="account-recovery-confirm-key"
getParamsFromModel={() => {
createLinkModel={() => {
return new CompleteResetPasswordLink(urlQueryData);
}}
// TODO worth fixing this type and adding integrations for AccountRecoveryConfirmKey?
integration={createMockResetPasswordWebIntegration() as Integration}
>
{({ setLinkStatus, params }) => (
<AccountRecoveryConfirmKey {...{ setLinkStatus, params }} />
{({ setLinkStatus, linkModel }) => (
<AccountRecoveryConfirmKey {...{ setLinkStatus, linkModel }} />
)}
</LinkValidator>
),

Просмотреть файл

@ -98,6 +98,17 @@ const AccountRecoveryResetPassword = ({
}
if (linkStatus === 'expired') {
if (isOAuthIntegration(integration)) {
const service = integration.getService();
const redirectUri = integration.getRedirectUri();
return (
<LinkExpiredResetPassword
email={verificationInfo.email}
{...{ viewName, service, redirectUri }}
/>
);
}
return (
<LinkExpiredResetPassword
email={verificationInfo.email}

Просмотреть файл

@ -44,16 +44,16 @@ const CompleteResetPasswordContainer = ({
path="/complete_reset_password/*"
linkType={LinkType['reset-password']}
viewName="complete-reset-password"
getParamsFromModel={() => {
createLinkModel={() => {
return CreateCompleteResetPasswordLink();
}}
{...{ integration }}
>
{({ setLinkStatus, params }) => (
{({ setLinkStatus, linkModel }) => (
<CompleteResetPassword
{...{
setLinkStatus,
params,
linkModel,
integration,
finishOAuthFlowHandler,
}}

Просмотреть файл

@ -404,7 +404,7 @@ describe('CompleteResetPassword page', () => {
render(<Subject />, account);
await enterPasswordAndSubmit();
expect(mockUseNavigateWithoutRerender).toHaveBeenCalledWith(
'/reset_password_verified?email=johndope%40example.com&emailToHashWith=&token=1111111111111111111111111111111111111111111111111111111111111111&code=11111111111111111111111111111111&uid=abc123',
'/reset_password_verified?email=johndope%40example.com&emailToHashWith=johndope%40example.com&token=1111111111111111111111111111111111111111111111111111111111111111&code=11111111111111111111111111111111&uid=abc123',
{
replace: true,
}

Просмотреть файл

@ -55,7 +55,7 @@ import {
export const viewName = 'complete-reset-password';
const CompleteResetPassword = ({
params,
linkModel,
setLinkStatus,
integration,
finishOAuthFlowHandler,
@ -110,7 +110,7 @@ const CompleteResetPassword = ({
const handleRecoveryKeyStatus = async () => {
if (!location.state?.lostRecoveryKey) {
await checkForRecoveryKeyAndNavigate(params.email);
await checkForRecoveryKeyAndNavigate(linkModel.email);
}
renderCompleteResetPassword();
};
@ -133,14 +133,14 @@ const CompleteResetPassword = ({
setShowLoadingSpinner(false);
logPageViewEvent(viewName, REACT_ENTRYPOINT);
};
checkPasswordForgotToken(params.token);
checkPasswordForgotToken(linkModel.token);
}, [
account,
navigate,
location.search,
location.state?.lostRecoveryKey,
params.email,
params.token,
linkModel.email,
linkModel.token,
setLinkStatus,
setShowLoadingSpinner,
]);
@ -339,7 +339,7 @@ const CompleteResetPassword = ({
to correctly save the updated password. Without it,
the password manager tries to save the old password
as the username. */}
<input type="email" value={params.email} className="hidden" readOnly />
<input type="email" value={linkModel.email} className="hidden" readOnly />
<section className="text-start mt-4">
<FormPasswordWithBalloons
{...{
@ -349,22 +349,22 @@ const CompleteResetPassword = ({
register,
getValues,
}}
email={params.email}
email={linkModel.email}
passwordFormType="reset"
onSubmit={handleSubmit(({ newPassword }) =>
onSubmit({
newPassword,
token: params.token,
code: params.code,
email: params.email,
emailToHashWith: params.emailToHashWith,
token: linkModel.token,
code: linkModel.code,
email: linkModel.email,
emailToHashWith: linkModel.emailToHashWith,
})
)}
loading={false}
onFocusMetricsEvent={`${viewName}.engage`}
/>
</section>
<LinkRememberPassword email={params.email} />
<LinkRememberPassword email={linkModel.email} />
</AppLayout>
);
};

Просмотреть файл

@ -29,7 +29,7 @@ export interface CompleteResetPasswordLocationState {
export interface CompleteResetPasswordParams {
email: string;
emailToHashWith: string;
emailToHashWith: string | undefined;
code: string;
token: string;
}
@ -44,7 +44,7 @@ export type CompleteResetPasswordIntegration =
| IntegrationSubsetType;
export interface CompleteResetPasswordProps {
params: CompleteResetPasswordLink;
linkModel: CompleteResetPasswordLink;
setLinkStatus: React.Dispatch<React.SetStateAction<LinkStatus>>;
integration: CompleteResetPasswordIntegration;
finishOAuthFlowHandler: FinishOAuthFlowHandler;

Просмотреть файл

@ -47,7 +47,7 @@ export const paramsWithMissingCode = {
export const paramsWithMissingEmailToHashWith = {
...mockCompleteResetPasswordParams,
emailToHashWith: '',
emailToHashWith: undefined,
};
export const paramsWithMissingToken = {
@ -68,7 +68,7 @@ export const Subject = ({
params = mockCompleteResetPasswordParams,
}: {
integrationType?: IntegrationType;
params?: Record<string, string>;
params?: Record<string, unknown>;
}) => {
const urlQueryData = mockUrlQueryData(params);
@ -90,15 +90,15 @@ export const Subject = ({
<LinkValidator
linkType={LinkType['reset-password']}
viewName={'complete-reset-password'}
getParamsFromModel={() => {
createLinkModel={() => {
return new CompleteResetPasswordLink(urlQueryData);
}}
// TODO worth fixing this type?
integration={completeResetPasswordIntegration as Integration}
>
{({ setLinkStatus, params }) => (
{({ setLinkStatus, linkModel }) => (
<CompleteResetPassword
{...{ setLinkStatus, params }}
{...{ setLinkStatus, linkModel }}
integration={completeResetPasswordIntegration}
finishOAuthFlowHandler={() =>
Promise.resolve({ redirect: 'someUri' })

Просмотреть файл

@ -31191,6 +31191,7 @@ fsevents@~2.1.1:
babel-plugin-named-exports-order: ^0.0.2
base32-decode: ^1.0.0
base32-encode: ^1.2.0
class-validator: ^0.14.0
classnames: ^2.3.1
css-loader: ^3.6.0
eslint: ^7.32.0