import { LitElement } from "lit"; import { state, queryAsync, property } from "lit/decorators.js"; 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 { @property({ type: String }) email!: string; @state() private isRequesting: boolean = false; @state() private requestSuccess: boolean = false; willUpdate(changedProperties: Map) { if (changedProperties.has("email")) { this.isRequesting = false; this.requestSuccess = false; } } createRenderRoot() { return this; } render() { if (this.requestSuccess) { return html`
${msg("Sent", { desc: "Status message after sending verification email", })}
`; } return html` ${this.isRequesting ? msg("Sending...") : msg("Resend verification email")} `; } private async requestVerification() { this.isRequesting = true; const resp = await fetch("/api/auth/request-verify-token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: this.email, }), }); switch (resp.status) { case 202: this.requestSuccess = true; break; default: // TODO generic toast error break; } this.isRequesting = false; } } customElements.define("btrix-request-verify", RequestVerify); @needLogin @localized() export class AccountSettings extends LiteElement { @property({ type: Object }) authState?: AuthState; @property({ type: Object }) userInfo?: CurrentUser; @state() sectionSubmitting: null | "name" | "email" | "password" = null; @state() private isChangingPassword = false; @state() private pwStrengthResults: null | ZxcvbnResult = null; @queryAsync('sl-input[name="password"]') private passwordInput?: Promise; async updated(changedProperties: Map) { if ( changedProperties.has("isChangingPassword") && this.isChangingPassword ) { (await this.passwordInput)?.focus(); } } protected firstUpdated() { PasswordService.setOptions(); } render() { if (!this.userInfo) return; return html`

${msg("Account Settings")}

${msg("Display Name")}

${msg( "Enter your full name, or another name to display in the orgs you belong to." )}

${msg("Save")}

${msg("Email")}

${msg("Update the email you use to log in.")}

${this.userInfo.isVerified ? html`` : html``}
${this.userInfo && !this.userInfo.isVerified ? html` ` : ""} ${msg("Save")}
${when( this.isChangingPassword, () => html`

${msg("Password")}

${when(this.pwStrengthResults, this.renderPasswordStrength)}

${msg( str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.` )}

${msg("Save")}
`, () => html`

${msg("Password")}

(this.isChangingPassword = true)} >${msg("Change Password")}
` )}
`; } private renderPasswordStrength = () => html`
`; 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; const input = form.querySelector("sl-input") as SlInput; if (!input.checkValidity()) { return; } e.preventDefault(); const newName = (serialize(form).displayName as string).trim(); if (newName === this.userInfo.name) { return; } this.sectionSubmitting = "name"; try { await this.apiFetch(`/users/me`, this.authState, { method: "PATCH", body: JSON.stringify({ email: this.userInfo.email, name: newName, }), }); this.dispatchEvent(new CustomEvent("update-user-info")); this.notify({ message: msg("Your name has been updated."), variant: "success", icon: "check2-circle", }); } catch (e) { this.notify({ message: msg("Sorry, couldn't update name at this time."), variant: "danger", icon: "exclamation-octagon", }); } this.sectionSubmitting = null; } private async onSubmitEmail(e: SubmitEvent) { if (!this.userInfo || !this.authState) return; const form = e.target as HTMLFormElement; const input = form.querySelector("sl-input") as SlInput; if (!input.checkValidity()) { return; } e.preventDefault(); const newEmail = (serialize(form).email as string).trim(); if (newEmail === this.userInfo.email) { return; } this.sectionSubmitting = "email"; try { await this.apiFetch(`/users/me`, this.authState, { method: "PATCH", body: JSON.stringify({ email: newEmail, }), }); this.dispatchEvent(new CustomEvent("update-user-info")); this.notify({ message: msg("Your email has been updated."), variant: "success", icon: "check2-circle", }); } catch (e) { this.notify({ message: msg("Sorry, couldn't update email at this time."), variant: "danger", icon: "exclamation-octagon", }); } this.sectionSubmitting = null; } private async onSubmitPassword(e: SubmitEvent) { if (!this.userInfo || !this.authState) return; const form = e.target as HTMLFormElement; const inputs = Array.from(form.querySelectorAll("sl-input")) as SlInput[]; if (inputs.some((input) => !input.checkValidity())) { return; } e.preventDefault(); const { password, newPassword } = serialize(form); this.sectionSubmitting = "password"; try { await this.apiFetch("/users/me/password-change", this.authState, { method: "PUT", body: JSON.stringify({ email: this.userInfo.email, password, newPassword, }), }); this.isChangingPassword = false; this.dispatchEvent(new CustomEvent("update-user-info")); this.notify({ message: msg("Your password has been updated."), variant: "success", icon: "check2-circle", }); } catch (e: any) { if (e.isApiError && e.details === "invalid_current_password") { this.notify({ message: msg("Please correct your current password and try again."), variant: "danger", icon: "exclamation-octagon", }); } else { this.notify({ message: msg("Sorry, couldn't update password at this time."), variant: "danger", icon: "exclamation-octagon", }); } } this.sectionSubmitting = null; } }