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
+
+```
+
+#### 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 | | no | n/a | n/a | disabled flag
+| `maxlength` | number | | yes | n/a | n/a | max length of the model value
+| `minlength` | number | | yes | n/a | n/a | min length of the model value
+| `pattern` | string<regexp> | @? | yes | n/a | n/a | pattern of the model value
+| `required` | boolean | | no | `true`, `false` | `false` | required flag
+| `on-change` | function | & | no | n/a | n/a | handler triggered when value has changed
+
+### oui-rule
+
+| Attribute | Type | Binding | One-time binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `pattern` | string<regexp> | @? | no | n/a | n/a | pattern of the model value
+| `validator` | function | & | no | n/a | n/a | validator function to test the password value; should return a boolean
+
+### oui-strength
+
+| Attribute | Type | Binding | One-time binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `score` | number | | no | See **Score scale** | n/a | score provided by a password strength estimator
+
+## Configuration
+
+The password translations can be globally configured with a provider.
+
+```js
+angular.module("myModule", [
+ "oui.password"
+]).config(ouiPasswordConfigurationProvider => {
+ 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(`
+ `, {
+ 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: "",
+ maxlength: "",
+ minlength: "",
+ pattern: "@?",
+ required: "",
+ onChange: "&"
+ },
+ controller,
+ template,
+ transclude: {
+ ruleSlot: "?ouiPasswordRule",
+ strengthSlot: "?ouiPasswordStrength"
+ }
+};
diff --git a/packages/oui-password/src/password.controller.js b/packages/oui-password/src/password.controller.js
new file mode 100644
index 00000000..c6bc5c44
--- /dev/null
+++ b/packages/oui-password/src/password.controller.js
@@ -0,0 +1,48 @@
+import { addBooleanParameter, addDefaultParameter } from "@ovh-ui/common/component-utils";
+
+export default class {
+ constructor ($attrs, $element, $scope, $timeout, ouiPasswordConfiguration) {
+ "ngInject";
+
+ this.$attrs = $attrs;
+ this.$element = $element;
+ this.$id = $scope.$id;
+ this.$timeout = $timeout;
+ this.translations = ouiPasswordConfiguration.translations;
+ }
+
+ toggleVisibility () {
+ this.isVisible = !this.isVisible;
+ }
+
+ updateValidity (key, isValid) {
+ if (isValid) {
+ delete this.errors[key];
+ } else {
+ this.errors[key] = true;
+ }
+
+ this.valid = !Object.keys(this.errors).length;
+ this.form[this.name].$setValidity("password", this.valid);
+ }
+
+ $onInit () {
+ addBooleanParameter(this, "disabled");
+ addBooleanParameter(this, "required");
+
+ addDefaultParameter(this, "id", `ouiPassword${this.$id}`);
+ addDefaultParameter(this, "name", `ouiPassword${this.$id}`);
+
+ this.errors = {};
+ this.isVisible = false;
+ }
+
+ $postLink () {
+ this.$timeout(() =>
+ 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: ""
+ },
+ controller,
+ template,
+ transclude: true
+};
diff --git a/packages/oui-password/src/strength/password-strength.controller.js b/packages/oui-password/src/strength/password-strength.controller.js
new file mode 100644
index 00000000..5a4bef21
--- /dev/null
+++ b/packages/oui-password/src/strength/password-strength.controller.js
@@ -0,0 +1,65 @@
+export default class {
+ constructor ($attrs, $element, $scope, $timeout, ouiPasswordConfiguration) {
+ "ngInject";
+
+ this.$attrs = $attrs;
+ this.$element = $element;
+ this.$scope = $scope;
+ this.$timeout = $timeout;
+ this.translations = ouiPasswordConfiguration.translations;
+ }
+
+ updateStrength (score) {
+ // By default, use zxcvbn score scale (from 0 to 4)
+ // Convert input score to this scale for the result
+ let currentScore = !angular.isNumber(score) ? 0 : Math.round(score);
+ currentScore = Math.max(currentScore, this.minScore);
+ currentScore = Math.min(currentScore, this.maxScore);
+
+ switch (currentScore) {
+ case this.scale.strong:
+ this.classname = "oui-progress_success";
+ this.feedback = this.translations.strongPasswordLabel;
+ break;
+ case this.scale.good:
+ this.classname = "oui-progress_success";
+ this.feedback = this.translations.goodPasswordLabel;
+ break;
+ case this.scale.weak:
+ this.classname = "oui-progress_warning";
+ this.feedback = this.translations.weakPasswordLabel;
+ break;
+ case this.scale.bad:
+ this.classname = "oui-progress_error";
+ this.feedback = this.translations.badPasswordLabel;
+ break;
+ default:
+ this.classname = "oui-progress_error";
+ this.feedback = this.translations.riskyPasswordLabel;
+ break;
+ }
+ }
+
+ $onInit () {
+ this.scale = {
+ strong: 4,
+ good: 3,
+ weak: 2,
+ bad: 1
+ };
+ this.minScore = 0;
+ this.maxScore = Object.keys(this.scale).length;
+ }
+
+ $postLink () {
+ this.$timeout(() =>
+ 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 @@
+
+
+
+