Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce strong passwords in UI #1266

Merged
merged 16 commits into from
Oct 13, 2023
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"@typescript-eslint/parser": "^5.4.0",
"@wysimark/standalone": "2.2.15",
"@xstate/fsm": "^1.6.2",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"autoprefixer": "^10.4.2",
"axios": "^0.22.0",
"broadcastchannel-polyfill": "^1.0.1",
Expand Down
51 changes: 50 additions & 1 deletion frontend/src/components/account-settings.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { LitElement } from "lit";
import { state, queryAsync, property } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import { msg, str, localized } from "@lit/localize";
import debounce from "lodash/fp/debounce";
import { when } from "lit/directives/when.js";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { SlInput } from "@shoelace-style/shoelace";
import type { ZxcvbnResult } from "@zxcvbn-ts/core";

import type { CurrentUser } from "../types/user";
import LiteElement, { html } from "../utils/LiteElement";
import { needLogin } from "../utils/auth";
import type { AuthState, Auth } from "../utils/AuthService";
import AuthService from "../utils/AuthService";
import PasswordService from "../utils/PasswordService";

const { PASSWORD_MINLENGTH, PASSWORD_MAXLENGTH, PASSWORD_MIN_SCORE } =
PasswordService;

@localized()
class RequestVerify extends LitElement {
Expand Down Expand Up @@ -98,6 +104,9 @@ export class AccountSettings extends LiteElement {
@state()
private isChangingPassword = false;

@state()
private pwStrengthResults: null | ZxcvbnResult = null;

@queryAsync('sl-input[name="password"]')
private passwordInput?: Promise<SlInput | null>;

Expand All @@ -110,6 +119,10 @@ export class AccountSettings extends LiteElement {
}
}

protected firstUpdated() {
PasswordService.setOptions();
}

render() {
if (!this.userInfo) return;
return html`
Expand Down Expand Up @@ -222,16 +235,26 @@ export class AccountSettings extends LiteElement {
password-toggle
minlength="8"
required
@input=${this.onPasswordInput}
></sl-input>

${when(this.pwStrengthResults, this.renderPasswordStrength)}
</div>
<footer
class="flex items-center justify-end border-t px-4 py-3"
>
<p class="mr-auto text-gray-500">
${msg(
str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.`
)}
</p>
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "password"}
?disabled=${!this.pwStrengthResults ||
this.pwStrengthResults.score < PASSWORD_MIN_SCORE}
>${msg("Save")}</sl-button
>
</footer>
Expand All @@ -255,6 +278,32 @@ export class AccountSettings extends LiteElement {
`;
}

private renderPasswordStrength = () => html`
<div class="mt-4">
<btrix-pw-strength-alert
.result=${this.pwStrengthResults}
min=${PASSWORD_MIN_SCORE}
>
</btrix-pw-strength-alert>
</div>
`;

private onPasswordInput = debounce(150)(async (e: InputEvent) => {
const { value } = e.target as SlInput;
if (!value || value.length < 4) {
this.pwStrengthResults = null;
return;
}
const userInputs: string[] = [];
if (this.userInfo) {
userInputs.push(this.userInfo.name, this.userInfo.email);
}
this.pwStrengthResults = await PasswordService.checkStrength(
value,
userInputs
);
}) as any;

private async onSubmitName(e: SubmitEvent) {
if (!this.userInfo || !this.authState) return;
const form = e.target as HTMLFormElement;
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ import("./collections-add").then(({ CollectionsAdd }) => {
import("./code").then(({ Code }) => {
customElements.define("btrix-code", Code);
});
import("./pw-strength-alert").then(({ PasswordStrengthAlert }) => {
customElements.define("btrix-pw-strength-alert", PasswordStrengthAlert);
});
import("./search-combobox").then(({ SearchCombobox }) => {
customElements.define("btrix-search-combobox", SearchCombobox);
});
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/components/input/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class Input extends LiteElement {
label?: string;

@property({ type: String })
id: string = "";
id: string = "customInput";

@property({ type: String })
name?: string;
Expand Down Expand Up @@ -51,7 +51,6 @@ export class Input extends LiteElement {

@state()
isPasswordVisible: boolean = false;

render() {
return html`
<div class="sl-label">
Expand Down
160 changes: 160 additions & 0 deletions frontend/src/components/pw-strength-alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { LitElement, html, css } from "lit";
import { msg, localized } from "@lit/localize";
import { property } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import type { ZxcvbnResult } from "@zxcvbn-ts/core";

/**
* Show results of password strength estimate
*
* Usage example:
* ```ts
* <btrix-pw-strength-alert .result=${this.zxcvbnResult}></btrix-pw-strength-alert>
* ```
*/
@localized()
export class PasswordStrengthAlert extends LitElement {
@property({ type: String })
result?: ZxcvbnResult;

/** Minimum acceptable score */
@property({ type: String })
min = 1;

/** Optimal score */
@property({ type: String })
optimal = 4;

static styles = css`
sl-alert::part(message) {
/* Decrease padding size: */
--sl-spacing-large: var(--sl-spacing-small);
}

sl-alert[variant="danger"] .icon {
color: var(--sl-color-danger-600);
}

sl-alert[variant="warning"] .icon {
color: var(--sl-color-warning-600);
}

sl-alert[variant="primary"] .icon {
color: var(--sl-color-primary-600);
}

sl-alert[variant="success"] .icon {
color: var(--sl-color-success-600);
}

p,
ul {
margin: 0;
padding: 0;
}

ul {
list-style-position: inside;
}

.score {
display: flex;
gap: var(--sl-spacing-x-small);
align-items: center;
}

.icon {
font-size: var(--sl-font-size-large);
}

.label {
color: var(--sl-color-neutral-900);
font-weight: var(--sl-font-weight-semibold);
}

.feedback {
color: var(--sl-color-neutral-700);
margin-left: var(--sl-spacing-x-large);
}

.text {
margin-top: var(--sl-spacing-small);
}
`;

render() {
if (!this.result) return;

const { score, feedback } = this.result;
let scoreProps = {
icon: "exclamation-triangle",
label: msg("Very weak password"),
variant: "danger",
};
switch (score) {
case 2:
scoreProps = {
icon: "exclamation-circle",
label: msg("Weak password"),
variant: "warning",
};
break;
case 3:
scoreProps = {
icon: "shield-check",
label: msg("Acceptably strong password"),
variant: "primary",
};
break;
case 4:
scoreProps = {
icon: "shield-fill-check",
label: msg("Very strong password"),
variant: "success",
};
break;
default:
break;
}
if (score < this.min) {
scoreProps.label = msg("Please choose a stronger password");
}
return html`
<sl-alert variant=${scoreProps.variant as any} open>
<div class="score">
<sl-icon class="icon" name=${scoreProps.icon}></sl-icon>
<p class="label">${scoreProps.label}</p>
</div>

<div class="feedback">
${when(
feedback.warning,
() => html` <p class="text">${feedback.warning}</p> `
)}
${when(feedback.suggestions.length, () =>
feedback.suggestions.length === 1
? html`<p class="text">
${msg("Suggestion:")} ${feedback.suggestions[0]}
</p>`
: html`<p class="text">${msg("Suggestions:")}</p>
<ul>
${feedback.suggestions.map(
(text) => html`<li>${text}</li>`
)}
</ul>`
)}
${when(
score >= this.min && score < this.optimal,
() => html`
<p class="text">
${msg(
"Tip: To generate very strong passwords, consider using a password manager."
)}
</p>
`
)}
</div>
</sl-alert>
`;
}
}
Loading