diff --git a/frontend/package.json b/frontend/package.json index 862e5afa..9ab91cdc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/account-settings.ts b/frontend/src/components/account-settings.ts new file mode 100644 index 00000000..ecad97ee --- /dev/null +++ b/frontend/src/components/account-settings.ts @@ -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( + { + 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` +
+ ${this.formState.context.successMessage} +
+ `; + } + + return html`
+

${msg("Account settings")}

+ + ${successMessage} + +
+
+
Email
+
${this.authState!.username}
+
+ + ${showForm + ? this.renderChangePasswordForm() + : html` +
+ this._stateService.send("EDIT")} + >${msg("Change password")} +
+ `} +
+
`; + } + + renderChangePasswordForm() { + const passwordFieldError = this.formState.context.fieldErrors.password; + let formError; + + if (this.formState.context.serverError) { + formError = html` +
+ ${this.formState.context.serverError} +
+ `; + } + + return html`
+

${msg("Change password")}

+ +
+ + + ${passwordFieldError + ? html`` + : ""} +
+
+ + +
+
+ + +
+ + ${formError} + +
+ ${msg("Update password")} + this._stateService.send("CANCEL")} + >${msg("Cancel")} +
+
+
`; + } + + 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"), + }, + }); + } + } +} diff --git a/frontend/src/components/alert.ts b/frontend/src/components/alert.ts new file mode 100644 index 00000000..93f7bde0 --- /dev/null +++ b/frontend/src/components/alert.ts @@ -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 + * + * 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` + + `; + } +} diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 551e5891..9d939806 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -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()}
${this.renderPage()}
- +
`; @@ -122,7 +120,11 @@ export class App extends LiteElement { > - Your account + this.navigate(ROUTES.accountSettings)} + > + ${msg("Your account")} + ${msg("Log Out")} @@ -155,7 +157,7 @@ export class App extends LiteElement { ${navLink({ href: "/users", label: "Users" })} - ${template} +
${template}
`; @@ -187,6 +189,14 @@ export class App extends LiteElement { .authState="${this.authState}" >`); + case "accountSettings": + return appLayout(html``); + case "archive-info": case "archive-info-tab": return appLayout(html`) { + 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); diff --git a/frontend/src/pages/log-in.ts b/frontend/src/pages/log-in.ts index e1179290..abaddad3 100644 --- a/frontend/src/pages/log-in.ts +++ b/frontend/src/pages/log-in.ts @@ -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` +
+ ${this.loginError} +
+ `; + } + return html`
- +
@@ -29,22 +41,22 @@ export class LogInPage extends LiteElement { id="password" name="password" type="password" - label="Password" - placeholder="Password" + label="${msg("Password")}" required >
+ + ${formError} + Log in${msg("Log in")}
- -
${this.loginError}
`; @@ -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; } diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 66a4dce9..f3585c6f 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -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 diff --git a/frontend/src/utils/LiteElement.ts b/frontend/src/utils/LiteElement.ts index 685cca79..9111df41 100644 --- a/frontend/src/utils/LiteElement.ts +++ b/frontend/src/utils/LiteElement.ts @@ -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) { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 2d28faf7..2c4493b0 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -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)`, diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6ac3aac4..6af111a9 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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"