Allow users to change password (#25)

wip #21
This commit is contained in:
sua yoo 2021-11-23 17:01:08 -08:00 committed by GitHub
parent 04fbe6fc4d
commit 58eba70c68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 474 additions and 26 deletions

View File

@ -9,6 +9,7 @@
"@formatjs/intl-getcanonicallocales": "^1.8.0",
"@lit/localize": "^0.11.1",
"@shoelace-style/shoelace": "^2.0.0-beta.61",
"@xstate/fsm": "^1.6.2",
"axios": "^0.22.0",
"color": "^4.0.1",
"lit": "^2.0.0",

View File

@ -0,0 +1,341 @@
import { state, query } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import { createMachine, interpret, assign } from "@xstate/fsm";
import type { AuthState } from "../types/auth";
import LiteElement, { html } from "../utils/LiteElement";
import { needLogin } from "../utils/auth";
type FormContext = {
successMessage?: string;
serverError?: string;
fieldErrors: { [fieldName: string]: string };
};
type FormSuccessEvent = {
type: "SUCCESS";
detail: {
successMessage?: FormContext["successMessage"];
};
};
type FormErrorEvent = {
type: "ERROR";
detail: {
serverError?: FormContext["serverError"];
fieldErrors?: FormContext["fieldErrors"];
};
};
type FormEvent =
| { type: "EDIT" }
| { type: "CANCEL" }
| { type: "SUBMIT" }
| FormSuccessEvent
| FormErrorEvent;
type FormTypestate =
| {
value: "readOnly";
context: FormContext;
}
| {
value: "editingForm";
context: FormContext;
}
| {
value: "submittingForm";
context: FormContext;
};
const initialContext = {
fieldErrors: {},
};
const machine = createMachine<FormContext, FormEvent, FormTypestate>(
{
id: "changePasswordForm",
initial: "readOnly",
context: initialContext,
states: {
["readOnly"]: {
on: {
EDIT: {
target: "editingForm",
actions: "reset",
},
},
},
["editingForm"]: {
on: { CANCEL: "readOnly", SUBMIT: "submittingForm" },
},
["submittingForm"]: {
on: {
SUCCESS: {
target: "readOnly",
actions: "setSucessMessage",
},
ERROR: {
target: "editingForm",
actions: "setError",
},
},
},
},
},
{
actions: {
reset: assign(() => initialContext),
setSucessMessage: assign((context, event) => ({
...context,
...(event as FormSuccessEvent).detail,
})),
setError: assign((context, event) => ({
...context,
...(event as FormErrorEvent).detail,
})),
},
}
);
@needLogin
@localized()
export class AccountSettings extends LiteElement {
authState?: AuthState;
private _stateService = interpret(machine);
@state()
private formState = machine.initialState;
@query("#newPassword")
private newPasswordInput?: HTMLInputElement;
@query("#confirmNewPassword")
private confirmNewPasswordInput?: HTMLInputElement;
firstUpdated() {
this._stateService.subscribe((state) => {
this.formState = state;
});
this._stateService.start();
}
disconnectedCallback() {
this._stateService.stop();
}
checkPasswordMatch() {
const newPassword = this.newPasswordInput!.value;
const confirmNewPassword = this.confirmNewPasswordInput!.value;
if (newPassword === confirmNewPassword) {
this.confirmNewPasswordInput!.setCustomValidity("");
} else {
this.confirmNewPasswordInput!.setCustomValidity(
msg("Passwords don't match")
);
}
}
render() {
const showForm =
this.formState.value === "editingForm" ||
this.formState.value === "submittingForm";
let successMessage;
if (this.formState.context.successMessage) {
successMessage = html`
<div>
<bt-alert type="success"
>${this.formState.context.successMessage}</bt-alert
>
</div>
`;
}
return html`<div class="grid gap-4">
<h1 class="text-xl font-bold">${msg("Account settings")}</h1>
${successMessage}
<section class="p-4 md:p-8 border rounded-lg grid gap-6">
<div>
<div class="mb-1 text-gray-500">Email</div>
<div>${this.authState!.username}</div>
</div>
${showForm
? this.renderChangePasswordForm()
: html`
<div>
<sl-button
type="primary"
outline
@click=${() => this._stateService.send("EDIT")}
>${msg("Change password")}</sl-button
>
</div>
`}
</section>
</div>`;
}
renderChangePasswordForm() {
const passwordFieldError = this.formState.context.fieldErrors.password;
let formError;
if (this.formState.context.serverError) {
formError = html`
<div class="mb-5">
<bt-alert id="formError" type="danger"
>${this.formState.context.serverError}</bt-alert
>
</div>
`;
}
return html` <div class="max-w-sm">
<h3 class="font-bold mb-3">${msg("Change password")}</h3>
<sl-form @sl-submit="${this.onSubmit}" aria-describedby="formError">
<div class="mb-5">
<sl-input
id="password"
class="${passwordFieldError ? "text-danger" : ""}"
name="password"
type="password"
label="${msg("Current password")}"
aria-describedby="passwordError"
required
>
</sl-input>
${passwordFieldError
? html`<div id="passwordError" class="text-danger" role="alert">
${passwordFieldError}
</div>`
: ""}
</div>
<div class="mb-5">
<sl-input
id="newPassword"
name="newPassword"
type="password"
label="${msg("New password")}"
required
@sl-blur=${this.checkPasswordMatch}
>
</sl-input>
</div>
<div class="mb-5">
<sl-input
id="confirmNewPassword"
name="confirmNewPassword"
type="password"
label="${msg("Confirm new password")}"
required
@sl-blur=${this.checkPasswordMatch}
>
</sl-input>
</div>
${formError}
<div>
<sl-button
type="primary"
?loading=${this.formState.value === "submittingForm"}
submit
>${msg("Update password")}</sl-button
>
<sl-button
type="text"
@click=${() => this._stateService.send("CANCEL")}
>${msg("Cancel")}</sl-button
>
</div>
</sl-form>
</div>`;
}
async onSubmit(event: { detail: { formData: FormData } }) {
if (!this.authState) return;
this._stateService.send("SUBMIT");
const { formData } = event.detail;
let nextAuthState: AuthState = null;
// Validate current password by generating token
try {
// TODO consolidate with log-in method
const resp = await fetch("/api/auth/jwt/login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "password",
username: this.authState.username,
password: formData.get("password") as string,
}).toString(),
});
const data = await resp.json();
if (data.token_type === "bearer" && data.access_token) {
const detail = {
api: true,
auth: `Bearer ${data.access_token}`,
username: this.authState.username,
};
this.dispatchEvent(new CustomEvent("logged-in", { detail }));
nextAuthState = {
username: detail.username,
headers: {
Authorization: detail.auth,
},
};
}
} catch (e) {
console.error(e);
}
if (!nextAuthState) {
this._stateService.send({
type: "ERROR",
detail: {
fieldErrors: {
password: msg("Wrong password"),
},
},
});
return;
}
const params = {
password: formData.get("newPassword"),
};
try {
await this.apiFetch("/users/me", nextAuthState, {
method: "PATCH",
body: JSON.stringify(params),
});
this._stateService.send({
type: "SUCCESS",
detail: {
successMessage: "Successfully updated password",
},
});
} catch (e) {
console.error(e);
this._stateService.send({
type: "ERROR",
detail: {
serverError: msg("Something went wrong changing password"),
},
});
}
}
}

View File

@ -0,0 +1,52 @@
import { LitElement, html, css } from "lit";
import { property } from "lit/decorators.js";
/**
* Alert used inline, e.g. for form server errors
*
* Usage example:
* ```ts
* <input aria-describedby="error_message" />
* <bt-alert id="error_message>${errorMessage}</bt-alert>
* ```
*/
export class Alert extends LitElement {
@property({ type: String })
type: "success" | "warning" | "danger" | "info" = "info";
static styles = css`
:host > div {
padding: var(--sl-spacing-x-small) var(--sl-spacing-small);
border-radius: var(--sl-border-radius-medium);
}
.success {
background-color: var(--sl-color-success-50);
color: var(--success);
}
.warning {
background-color: var(--sl-color-warning-50);
color: var(--warning);
}
.danger {
background-color: var(--sl-color-danger-50);
color: var(--danger);
}
.info {
background-color: var(--sl-color-sky-50);
color: var(--sl-color-sky-600);
}
`;
render() {
console.log("id:", this.id);
return html`
<div class="${this.type}" role="alert">
<slot></slot>
</div>
`;
}
}

View File

@ -1,9 +1,11 @@
import type { TemplateResult } from "lit";
import { state } from "lit/decorators.js";
import { msg, updateWhenLocaleChanges } from "@lit/localize";
import { msg, localized } from "@lit/localize";
import "./shoelace";
import { LocalePicker } from "./components/locale-picker";
import { Alert } from "./components/alert";
import { AccountSettings } from "./components/account-settings";
import { LogInPage } from "./pages/log-in";
import { MyAccountPage } from "./pages/my-account";
import { ArchivePage } from "./pages/archive-info";
@ -18,11 +20,12 @@ const ROUTES = {
home: "/",
login: "/log-in",
myAccount: "/my-account",
accountSettings: "/account/settings",
"archive-info": "/archive/:aid",
"archive-info-tab": "/archive/:aid/:tab",
} as const;
// ===========================================================================
@localized()
export class App extends LiteElement {
router: APIRouter;
@ -39,11 +42,6 @@ export class App extends LiteElement {
constructor() {
super();
// Note we use updateWhenLocaleChanges here so that we're always up to date with
// the active locale (the result of getLocale()) when the locale changes via a
// history navigation.
updateWhenLocaleChanges(this);
const authState = window.localStorage.getItem("authState");
if (authState) {
this.authState = JSON.parse(authState);
@ -96,7 +94,7 @@ export class App extends LiteElement {
${this.renderNavBar()}
<main class="relative flex-auto flex">${this.renderPage()}</main>
<footer class="flex justify-center p-4 border-t">
<locale-picker></locale-picker>
<bt-locale-picker></bt-locale-picker>
</footer>
</div>
`;
@ -122,7 +120,11 @@ export class App extends LiteElement {
></span>
</div>
<sl-menu>
<sl-menu-item>Your account</sl-menu-item>
<sl-menu-item
@click=${() => this.navigate(ROUTES.accountSettings)}
>
${msg("Your account")}
</sl-menu-item>
<sl-menu-item @click="${this.onLogOut}"
>${msg("Log Out")}</sl-menu-item
>
@ -155,7 +157,7 @@ export class App extends LiteElement {
${navLink({ href: "/users", label: "Users" })}
</ul>
</nav>
${template}
<div class="p-4 md:p-8 flex-1">${template}</div>
</div>
`;
@ -187,6 +189,14 @@ export class App extends LiteElement {
.authState="${this.authState}"
></my-account>`);
case "accountSettings":
return appLayout(html`<btrix-account-settings
class="w-full"
@navigate="${this.onNavigateTo}"
@need-login="${this.onNeedLogin}"
.authState="${this.authState}"
></btrix-account-settings>`);
case "archive-info":
case "archive-info-tab":
return appLayout(html`<btrix-archive
@ -208,13 +218,19 @@ export class App extends LiteElement {
this.navigate("/");
}
onLoggedIn(event: CustomEvent<{ auth: string; username: string }>) {
onLoggedIn(
event: CustomEvent<{ api?: boolean; auth: string; username: string }>
) {
const { detail } = event;
this.authState = {
username: event.detail.username,
headers: { Authorization: event.detail.auth },
username: detail.username,
headers: { Authorization: detail.auth },
};
window.localStorage.setItem("authState", JSON.stringify(this.authState));
this.navigate(ROUTES.myAccount);
if (!detail.api) {
this.navigate(ROUTES.myAccount);
}
}
onNeedLogin(event?: CustomEvent<{ api: boolean }>) {
@ -236,9 +252,11 @@ export class App extends LiteElement {
}
}
customElements.define("locale-picker", LocalePicker);
customElements.define("bt-alert", Alert);
customElements.define("bt-locale-picker", LocalePicker);
customElements.define("browsertrix-app", App);
customElements.define("log-in", LogInPage);
customElements.define("my-account", MyAccountPage);
customElements.define("btrix-archive", ArchivePage);
customElements.define("btrix-archive-configs", ArchiveConfigsPage);
customElements.define("btrix-account-settings", AccountSettings);

View File

@ -1,7 +1,10 @@
import { state, property } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import LiteElement, { html } from "../utils/LiteElement";
import type { Auth } from "../types/auth";
@localized()
export class LogInPage extends LiteElement {
@state()
isLoggingIn: boolean = false;
@ -10,16 +13,25 @@ export class LogInPage extends LiteElement {
loginError?: string;
render() {
let formError;
if (this.loginError) {
formError = html`
<div class="mb-5">
<bt-alert id="formError" type="danger">${this.loginError}</bt-alert>
</div>
`;
}
return html`
<div class="md:bg-white md:shadow-2xl md:rounded-lg md:px-12 md:py-12">
<div class="max-w-md">
<sl-form @sl-submit="${this.onSubmit}">
<sl-form @sl-submit="${this.onSubmit}" aria-describedby="formError">
<div class="mb-5">
<sl-input
id="username"
name="username"
label="Username"
placeholder="Username"
label="${msg("Username")}"
required
>
</sl-input>
@ -29,22 +41,22 @@ export class LogInPage extends LiteElement {
id="password"
name="password"
type="password"
label="Password"
placeholder="Password"
label="${msg("Password")}"
required
>
</sl-input>
</div>
${formError}
<sl-button
class="w-full"
type="primary"
?loading=${this.isLoggingIn}
submit
>Log in</sl-button
>${msg("Log in")}</sl-button
>
</sl-form>
<div id="login-error" class="text-red-600">${this.loginError}</div>
</div>
</div>
`;
@ -72,7 +84,7 @@ export class LogInPage extends LiteElement {
});
if (resp.status !== 200) {
this.isLoggingIn = false;
this.loginError = "Sorry, invalid credentials";
this.loginError = msg("Sorry, invalid username or password");
return;
}

View File

@ -14,7 +14,11 @@ const primaryColor = Color(PRIMARY_COLOR);
const theme = css`
:root {
/* contextual variables */
--primary: ${unsafeCSS(PRIMARY_COLOR)};
--success: var(--sl-color-success-600);
--warning: var(--sl-color-warning-600);
--danger: var(--sl-color-danger-600);
/*
* Theme Tokens

View File

@ -25,8 +25,20 @@ export default class LiteElement extends LitElement {
);
}
async apiFetch(path: string, auth: Auth) {
const resp = await fetch("/api" + path, { headers: auth.headers });
async apiFetch(
path: string,
auth: Auth,
options?: { method?: string; headers?: any; body?: any }
) {
const { headers, ...opts } = options || {};
const resp = await fetch("/api" + path, {
headers: {
"Content-Type": "application/json",
...headers,
...auth.headers,
},
...opts,
});
if (resp.status !== 200) {
if (resp.status === 401) {

View File

@ -24,6 +24,9 @@ function makeTheme() {
colors: {
...colors.map(makeColorPalette),
primary: `var(--primary)`,
success: `var(--success)`,
warning: `var(--warning)`,
danger: `var(--danger)`,
},
fontFamily: {
sans: `var(--sl-font-sans)`,

View File

@ -1041,6 +1041,11 @@
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d"
integrity sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A==
"@xstate/fsm@^1.6.2":
version "1.6.2"
resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.2.tgz#177a6920ba0d7d8522585641adc42ba59ecd9e36"
integrity sha512-vOfiFVQu9mQceA8oJ3PcA4vwhtyo/j/mbVDVIlHDOh3iuiTqMnp805zZ3QsouRdO2Ie3B7n3jMw8BntI74fZxg==
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"