diff --git a/index.html b/index.html deleted file mode 100644 index fef47ab0..00000000 --- a/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - Preview - - - - - - - - diff --git a/packages/oui-angular/src/index.js b/packages/oui-angular/src/index.js index 62b6ec9d..04abcbef 100644 --- a/packages/oui-angular/src/index.js +++ b/packages/oui-angular/src/index.js @@ -24,6 +24,7 @@ import Navbar from "@ovh-ui/oui-navbar"; import Numeric from "@ovh-ui/oui-numeric"; import PageHeader from "@ovh-ui/oui-page-header"; import Pagination from "@ovh-ui/oui-pagination"; +import Password from "@ovh-ui/oui-password"; import Popover from "@ovh-ui/oui-popover"; import Progress from "@ovh-ui/oui-progress"; import Radio from "@ovh-ui/oui-radio"; @@ -68,6 +69,7 @@ export default angular Numeric, PageHeader, Pagination, + Password, Popover, Progress, Radio, diff --git a/packages/oui-angular/src/index.spec.js b/packages/oui-angular/src/index.spec.js index d62f271c..a75ee49c 100644 --- a/packages/oui-angular/src/index.spec.js +++ b/packages/oui-angular/src/index.spec.js @@ -26,6 +26,7 @@ loadTests(require.context("../../oui-navbar/src/", true, /.*((\.spec)|(index))$/ loadTests(require.context("../../oui-numeric/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-page-header/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-pagination/src/", true, /.*((\.spec)|(index))$/)); +loadTests(require.context("../../oui-password/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-popover/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-progress/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-radio/src/", true, /.*((\.spec)|(index))$/)); diff --git a/packages/oui-field/src/field.controller.js b/packages/oui-field/src/field.controller.js index 88eff445..4d1fd7e2 100644 --- a/packages/oui-field/src/field.controller.js +++ b/packages/oui-field/src/field.controller.js @@ -192,7 +192,7 @@ export default class FieldController { } getMessageString (errorName) { - return (this.errorMessages && this.errorMessages[errorName]) || this.ouiFieldConfiguration.translations.errors[errorName]; + return (this.errorMessages && this.errorMessages[errorName]) || this.ouiFieldConfiguration.translations.errors[errorName] || this.ouiFieldConfiguration.translations.errors.invalid; } getErrorMessage (errorName) { diff --git a/packages/oui-field/src/field.provider.js b/packages/oui-field/src/field.provider.js index 738e1d56..c215c568 100644 --- a/packages/oui-field/src/field.provider.js +++ b/packages/oui-field/src/field.provider.js @@ -4,9 +4,11 @@ export default class { constructor () { this.translations = { errors: { + invalid: "Invalid field.", required: "Mandatory.", number: "Invalid number.", email: "Invalid email.", + password: "Invalid password.", min: "Too low ({{min}} min).", max: "Too high ({{max}} max).", minlength: "Too short ({{minlength}} characters min).", diff --git a/packages/oui-password/README.md b/packages/oui-password/README.md new file mode 100644 index 00000000..4647e5f0 --- /dev/null +++ b/packages/oui-password/README.md @@ -0,0 +1,143 @@ +# oui-password + + + +## Usage + +### Basic + +```html:preview + +``` + +### Placeholder + +```html:preview + +``` + +### Disabled + +```html:preview + +``` + +### Form validation + +```html:preview +
+ + + + +
+``` + +### Password rules & strength + + + The component doesn't include any password strength estimator. You can use one like zxcvbn to provide a score, like in this example. + + +```html:preview +
+ + + + + Must contain between 8 and 30 characters + + + Have at least one number + + + Have at least capital letter + + + +
+``` + +#### Custom strength feedback + +The feedback of password strength can be overridden by adding your custom feedback in `oui-password-strength`. +It can also be globally changed with `ouiPasswordProvider` (see **Configuration** below). + +```html:preview + + + Score 4: Etiam volutpat congue odio imperdiet tincidunt. + Score 3: Suspendisse vehicula ut nisl non laoreet. + Score 2: Curabitur malesuada mi lectus, eget pharetra erat malesuada sed. + Score 1: Vestibulum pulvinar congue lacus sed ultricies. + Score 0: Lorem ipsum dolor sit amet. + + +``` + +#### Score scale + +`oui-password-strength`'s score scale is based on zxcvbn scale: + +* `0`: Risky password, +* `1`: Bad password, +* `2`: Weak password, +* `3`: Good password, +* `4`: Strong password + +## API + +### oui-password + +| Attribute | Type | Binding | One-time binding | Values | Default | Description +| ---- | ---- | ---- | ---- | ---- | ---- | ---- +| `model` | string | = | no | n/a | n/a | model bound to component +| `id` | string | @? | yes | n/a | n/a | id attribute of the input +| `name` | string | @? | yes | n/a | n/a | name attritebu of the input +| `placeholder` | string | @? | yes | `true`, `false` | `false` | placeholder text +| `disabled` | boolean | { + ouiPasswordConfigurationProvider.setTranslations({ + allRulesValidLabel: "All password rules are met.", + ariaHidePasswordLabel: "Hide password", + ariaShowPasswordLabel: "Show password", + ariaValidRuleLabel: "Valid rule.", + ariaInvalidRuleLabel: "Invalid rule.", + riskyPasswordLabel: "Risky password.", + badPasswordLabel: "Bad password.", + weakPasswordLabel: "Weak password.", + goodPasswordLabel: "Good password.", + strongPasswordLabel: "Strong password." + }); +}); +``` diff --git a/packages/oui-password/package.json b/packages/oui-password/package.json new file mode 100644 index 00000000..e0f266b4 --- /dev/null +++ b/packages/oui-password/package.json @@ -0,0 +1,7 @@ +{ + "name": "@ovh-ui/oui-password", + "version": "1.0.0", + "main": "./src/index.js", + "license": "BSD-3-Clause", + "author": "OVH SAS" +} diff --git a/packages/oui-password/src/index.js b/packages/oui-password/src/index.js new file mode 100644 index 00000000..967eaed3 --- /dev/null +++ b/packages/oui-password/src/index.js @@ -0,0 +1,12 @@ +import Password from "./password.component"; +import PasswordConfigurationProvider from "./password.provider"; +import PasswordRule from "./rule/password-rule.component"; +import PasswordStrength from "./strength/password-strength.component"; + +export default angular + .module("oui.password", []) + .component("ouiPassword", Password) + .component("ouiPasswordRule", PasswordRule) + .component("ouiPasswordStrength", PasswordStrength) + .provider("ouiPasswordConfiguration", PasswordConfigurationProvider) + .name; diff --git a/packages/oui-password/src/index.spec.js b/packages/oui-password/src/index.spec.js new file mode 100644 index 00000000..abab4053 --- /dev/null +++ b/packages/oui-password/src/index.spec.js @@ -0,0 +1,250 @@ +describe("ouiPassword", () => { + let $timeout; + let TestUtils; + + const getInput = (element) => angular.element(element[0].querySelector(".oui-password__input")); + const getStrengthMeter = (element) => angular.element(element[0].querySelector(".oui-progress")); + const getVisibilityButton = (element) => angular.element(element[0].querySelector(".oui-password__visibility")); + + beforeEach(angular.mock.module("oui.password")); + beforeEach(angular.mock.module("oui.password.configuration")); + beforeEach(angular.mock.module("oui.test-utils")); + + beforeEach(inject((_$timeout_, _TestUtils_) => { + $timeout = _$timeout_; + TestUtils = _TestUtils_; + })); + + describe("Provider", () => { + let configuration; + const foo = { foo: "bar" }; + + angular.module("oui.password.configuration", [ + "oui.password" + ]).config(ouiPasswordConfigurationProvider => { + ouiPasswordConfigurationProvider.setTranslations(foo); + }); + + beforeEach(inject(_ouiPasswordConfiguration_ => { + configuration = _ouiPasswordConfiguration_; + })); + + it("should have custom translations", () => { + expect(configuration.translations.foo).toEqual("bar"); + }); + }); + + describe("Component", () => { + describe("Basic", () => { + let element; + let input; + let controller; + + beforeEach(() => { + element = TestUtils.compileTemplate(''); + + $timeout.flush(); + + controller = element.controller("ouiPassword"); + input = getInput(element); + }); + + it("should have a default classname, id and name", () => { + expect(element.hasClass("oui-password")).toBeTruthy(); + }); + + it("should move id and name on input", () => { + expect(element.attr("id")).toBeUndefined(); + expect(element.attr("name")).toBeUndefined(); + + expect(input.attr("id")).toBe("foo"); + expect(input.attr("name")).toBe("bar"); + }); + + it("should switch between input password and text", () => { + const button = getVisibilityButton(element); + + expect(input.attr("type")).toBe("password"); + button.triggerHandler("click"); + expect(input.attr("type")).toBe("text"); + button.triggerHandler("click"); + expect(input.attr("type")).toBe("password"); + }); + + it("should have disabled input", () => { + expect(input.attr("disabled")).toBeDefined(); + + controller.disabled = false; + element.scope().$digest(); + expect(input.attr("disabled")).toBeUndefined(); + + controller.disabled = true; + element.scope().$digest(); + expect(input.attr("disabled")).toBeDefined(); + }); + }); + + describe("Validation", () => { + let form; + let element; + let controller; + let input; + + beforeEach(() => { + form = TestUtils.compileTemplate(` +
+ + +
`); + + $timeout.flush(); + + element = form.find("oui-password"); + controller = element.controller("ouiPassword"); + input = getInput(element); + }); + + it("should get an error 'minlength'", () => { + input.val("foo"); + input.triggerHandler("input"); + + expect(controller.form.$error).toBeTruthy(); + expect(controller.form.$error.minlength).toBeTruthy(); + }); + + it("should get an error 'maxlength'", () => { + input.val("valueoversixteencharacters"); + input.triggerHandler("input"); + + expect(controller.form.$error).toBeTruthy(); + expect(controller.form.$error.maxlength).toBeTruthy(); + }); + + it("should return error 'pattern'", () => { + input.val("!&()$"); + input.triggerHandler("input"); + + expect(controller.form.$error).toBeTruthy(); + expect(controller.form.$error.pattern).toBeTruthy(); + }); + + it("should return error 'required'", () => { + form.triggerHandler("submit"); + + expect(controller.form.$error).toBeTruthy(); + expect(controller.form.$error.required).toBeTruthy(); + }); + }); + + describe("Strength", () => { + const compileStrength = (score) => TestUtils.compileTemplate(` + + + `, { + score + }); + + it("should have a default classname", () => { + const element = compileStrength(); + $timeout.flush(); + + const strength = element.find("oui-password-strength"); + expect(strength.hasClass("oui-password-strength")).toBeTruthy(); + + const meter = getStrengthMeter(element); + expect(meter.hasClass("oui-progress_error")).toBeTruthy(); + }); + + it("should have bad score", () => { + const element = compileStrength(1); + const meter = getStrengthMeter(element); + expect(meter.hasClass("oui-progress_error")).toBeTruthy(); + }); + + it("should have weak score", () => { + const element = compileStrength(2); + const meter = getStrengthMeter(element); + expect(meter.hasClass("oui-progress_warning")).toBeTruthy(); + }); + + it("should have good score", () => { + const element = compileStrength(3); + const meter = getStrengthMeter(element); + expect(meter.hasClass("oui-progress_success")).toBeTruthy(); + }); + + it("should have strong score", () => { + const element = compileStrength(4); + const meter = getStrengthMeter(element); + expect(meter.hasClass("oui-progress_success")).toBeTruthy(); + }); + }); + + describe("Rule", () => { + let form; + let element; + let controller; + let input; + + beforeEach(() => { + form = TestUtils.compileTemplate(` +
+ + + Must contain between 8 and 30 characters + + + Have at least one number + + + Have at least capital letter + + +
`, { + checkPasswordLength: (password) => { + const minLength = 8; + const maxLength = 30; + return angular.isString(password) && password.length >= minLength && password.length <= maxLength; + } + }); + + $timeout.flush(); + + element = form.find("oui-password"); + controller = element.controller("ouiPassword"); + input = getInput(element); + }); + + it("should have a default classname", () => { + expect(element.find("oui-password-rule").hasClass("oui-password-rule")).toBeTruthy(); + }); + + it("should return error 'password' for invalid rules", () => { + const invalidRules = 3; + input.val("foo"); + input.triggerHandler("input"); + + expect(controller.valid).toBeFalsy(); + expect(Object.keys(controller.errors).length).toBe(invalidRules); + expect(controller.form.$error).toBeTruthy(); + expect(controller.form.$error.password).toBeTruthy(); + expect(controller.form.$invalid).toBeTruthy(); + }); + + it("should return error 'password' for invalid rules", () => { + const invalidRules = 0; + input.val("F0azeruiop"); + input.triggerHandler("input"); + + expect(controller.valid).toBeTruthy(); + expect(Object.keys(controller.errors).length).toBe(invalidRules); + expect(controller.form.$valid).toBeTruthy(); + }); + }); + }); +}); diff --git a/packages/oui-password/src/password.component.js b/packages/oui-password/src/password.component.js new file mode 100644 index 00000000..3575abbd --- /dev/null +++ b/packages/oui-password/src/password.component.js @@ -0,0 +1,26 @@ +import controller from "./password.controller"; +import template from "./password.html"; + +export default { + require: { + form: "?^^form" + }, + bindings: { + model: "=", + id: "@?", + name: "@?", + placeholder: "@?", + disabled: " + this.$element + .removeAttr("id") + .removeAttr("name") + .addClass("oui-password") + ); + } +} diff --git a/packages/oui-password/src/password.html b/packages/oui-password/src/password.html new file mode 100644 index 00000000..baaffdae --- /dev/null +++ b/packages/oui-password/src/password.html @@ -0,0 +1,55 @@ +
+ + + + + + +
+ + +
+
+ + + +
+
+ + + +
+ + +
+ diff --git a/packages/oui-password/src/password.provider.js b/packages/oui-password/src/password.provider.js new file mode 100644 index 00000000..0ec2ec0f --- /dev/null +++ b/packages/oui-password/src/password.provider.js @@ -0,0 +1,33 @@ +import merge from "lodash/merge"; + +export default class { + constructor () { + this.translations = { + allRulesValidLabel: "All password rules are met.", + ariaHidePasswordLabel: "Hide password", + ariaShowPasswordLabel: "Show password", + ariaValidRuleLabel: "Valid rule.", + ariaInvalidRuleLabel: "Invalid rule.", + riskyPasswordLabel: "Risky password.", + badPasswordLabel: "Bad password.", + weakPasswordLabel: "Weak password.", + goodPasswordLabel: "Good password.", + strongPasswordLabel: "Strong password." + }; + } + + /** + * Set the translations + * @param {Object} translations a map of translations + */ + setTranslations (translations) { + this.translations = merge(this.translations, translations); + return this; + } + + $get () { + return { + translations: this.translations + }; + } +} diff --git a/packages/oui-password/src/rule/password-rule.component.js b/packages/oui-password/src/rule/password-rule.component.js new file mode 100644 index 00000000..0ae06be2 --- /dev/null +++ b/packages/oui-password/src/rule/password-rule.component.js @@ -0,0 +1,16 @@ +import controller from "./password-rule.controller"; +import template from "./password-rule.html"; + +export default { + require: { + password: "^ouiPassword" + }, + bindings: { + caption: "@?", + pattern: "@?", + validator: "&" + }, + controller, + template, + transclude: true +}; diff --git a/packages/oui-password/src/rule/password-rule.controller.js b/packages/oui-password/src/rule/password-rule.controller.js new file mode 100644 index 00000000..21cd42c5 --- /dev/null +++ b/packages/oui-password/src/rule/password-rule.controller.js @@ -0,0 +1,38 @@ +export default class { + constructor ($attrs, $element, $scope, $timeout, ouiPasswordConfiguration) { + "ngInject"; + + this.$attrs = $attrs; + this.$element = $element; + this.$timeout = $timeout; + this.$scope = $scope; + this.translations = ouiPasswordConfiguration.translations; + } + + setValidity (value) { + if (this.pattern) { + const regexp = new RegExp(this.pattern); + this.valid = regexp.test(value); + } else if (this.validator) { + this.valid = this.validator({ modelValue: value }); + } + + this.password.updateValidity(this.name, this.valid); + } + + $onInit () { + this.name = `ouiPasswordRule${this.$scope.$id}`; + } + + $postLink () { + this.$timeout(() => + this.$element + .addClass("oui-password-rule") + ); + + this.$scope.$watch( + () => this.password.model, + (value) => this.setValidity(value) + ); + } +} diff --git a/packages/oui-password/src/rule/password-rule.html b/packages/oui-password/src/rule/password-rule.html new file mode 100644 index 00000000..b05289b8 --- /dev/null +++ b/packages/oui-password/src/rule/password-rule.html @@ -0,0 +1,12 @@ + + + diff --git a/packages/oui-password/src/strength/password-strength.component.js b/packages/oui-password/src/strength/password-strength.component.js new file mode 100644 index 00000000..7eecc820 --- /dev/null +++ b/packages/oui-password/src/strength/password-strength.component.js @@ -0,0 +1,15 @@ +import controller from "./password-strength.controller"; +import template from "./password-strength.html"; + +export default { + require: { + password: "^ouiPassword" + }, + bindings: { + label: "@?", + score: " + this.$element + .addClass("oui-password-strength") + ); + + this.$scope.$watch( + () => this.score, + (score) => this.updateStrength(score) + ); + } +} diff --git a/packages/oui-password/src/strength/password-strength.html b/packages/oui-password/src/strength/password-strength.html new file mode 100644 index 00000000..d2e23aa3 --- /dev/null +++ b/packages/oui-password/src/strength/password-strength.html @@ -0,0 +1,10 @@ +

+ +

+ +