parent
04fbe6fc4d
commit
58eba70c68
@ -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",
|
||||
|
||||
341
frontend/src/components/account-settings.ts
Normal file
341
frontend/src/components/account-settings.ts
Normal 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"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
52
frontend/src/components/alert.ts
Normal file
52
frontend/src/components/alert.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)`,
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user