browsertrix/frontend/src/components/account-settings.ts
Ilya Kreymer 9a2787f9c4
User refactor + remove fastapi_users dependency + update fastapi (#1290)
Fixes #1050 

Major refactor of the user/auth system to remove fastapi_users
dependency. Refactors users.py to be standalone
and adds new auth.py module for handling auth. UserManager now works
similar to other ops classes.

The auth should be fully backwards compatible with fastapi_users auth,
including accepting previous JWT tokens w/o having to re-login. The User
data model in mongodb is also unchanged.

Additional fixes:
- allows updating fastapi to latest
- add webhook docs to openapi (follow up to #1041)

API changes:
- Removing the`GET, PATCH, DELETE /users/<id>` endpoints, which were not
in used before, as users are scoped to orgs. For deletion, probably
auto-delete when user is removed from last org (to be implemented).
- Rename `/users/me-with-orgs` is renamed to just `/users/me/`
- New `PUT /users/me/change-password` endpoint with password required to update password, fixes  #1269, supersedes #1272 

Frontend changes:
- Fixes from #1272 to support new change password endpoint.

---------
Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
Co-authored-by: sua yoo <sua@suayoo.com>
2023-10-18 10:49:23 -07:00

436 lines
13 KiB
TypeScript

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<string, any>) {
if (changedProperties.has("email")) {
this.isRequesting = false;
this.requestSuccess = false;
}
}
createRenderRoot() {
return this;
}
render() {
if (this.requestSuccess) {
return html`
<div class="text-sm text-gray-500 inline-flex items-center">
<sl-icon class="mr-1" name="check-lg"></sl-icon> ${msg("Sent", {
desc: "Status message after sending verification email",
})}
</div>
`;
}
return html`
<span
class="text-sm text-primary hover:text-indigo-400"
role="button"
?disabled=${this.isRequesting}
@click=${this.requestVerification}
>
${this.isRequesting
? msg("Sending...")
: msg("Resend verification email")}
</span>
`;
}
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<SlInput | null>;
async updated(changedProperties: Map<string, any>) {
if (
changedProperties.has("isChangingPassword") &&
this.isChangingPassword
) {
(await this.passwordInput)?.focus();
}
}
protected firstUpdated() {
PasswordService.setOptions();
}
render() {
if (!this.userInfo) return;
return html`
<div class="max-w-screen-sm mx-auto">
<h1 class="text-xl font-semibold leading-8 mb-7">
${msg("Account Settings")}
</h1>
<form class="border rounded mb-5" @submit=${this.onSubmitName}>
<div class="p-4">
<h2 class="text-lg font-semibold leading-none mb-4">
${msg("Display Name")}
</h2>
<p class="mb-2">
${msg(
"Enter your full name, or another name to display in the orgs you belong to."
)}
</p>
<sl-input
name="displayName"
value=${this.userInfo.name}
maxlength="40"
minlength="2"
required
aria-label=${msg("Display name")}
></sl-input>
</div>
<footer class="flex items-center justify-end border-t px-4 py-3">
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "name"}
>${msg("Save")}</sl-button
>
</footer>
</form>
<form class="border rounded mb-5" @submit=${this.onSubmitEmail}>
<div class="p-4">
<h2 class="text-lg font-semibold leading-none mb-4">
${msg("Email")}
</h2>
<p class="mb-2">${msg("Update the email you use to log in.")}</p>
<sl-input
name="email"
value=${this.userInfo.email}
type="email"
aria-label=${msg("Email")}
>
<div slot="suffix">
<sl-tooltip
content=${this.userInfo.isVerified
? msg("Verified")
: msg("Needs verification")}
hoist
>
${this.userInfo.isVerified
? html`<sl-icon
class="text-success"
name="check-lg"
></sl-icon>`
: html`<sl-icon
class="text-warning"
name="exclamation-circle"
></sl-icon>`}
</sl-tooltip>
</div>
</sl-input>
</div>
<footer class="flex items-center justify-end border-t px-4 py-3">
${this.userInfo && !this.userInfo.isVerified
? html`
<btrix-request-verify
class="mr-auto"
email=${this.userInfo.email}
></btrix-request-verify>
`
: ""}
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "email"}
>${msg("Save")}</sl-button
>
</footer>
</form>
<section class="border rounded mb-5">
${when(
this.isChangingPassword,
() => html`
<form @submit=${this.onSubmitPassword}>
<div class="p-4">
<h2 class="text-lg font-semibold leading-none mb-4">
${msg("Password")}
</h2>
<sl-input
class="mb-3"
name="password"
label=${msg("Enter your current password")}
type="password"
autocomplete="current-password"
password-toggle
required
></sl-input>
<sl-input
name="newPassword"
label=${msg("New password")}
type="password"
autocomplete="new-password"
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>
</form>
`,
() => html`
<div class="px-4 py-3 flex items-center justify-between">
<h2 class="text-lg font-semibold leading-none">
${msg("Password")}
</h2>
<sl-button
size="small"
@click=${() => (this.isChangingPassword = true)}
>${msg("Change Password")}</sl-button
>
</div>
`
)}
</section>
</div>
`;
}
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;
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;
}
}