parent
50e93724fc
commit
3fa85c83f2
@ -1,11 +1,75 @@
|
||||
import { state, query } from "lit/decorators.js";
|
||||
import { LitElement } from "lit";
|
||||
import { state, query, property } from "lit/decorators.js";
|
||||
import { msg, localized } from "@lit/localize";
|
||||
import { createMachine, interpret, assign } from "@xstate/fsm";
|
||||
|
||||
import type { AuthState } from "../types/auth";
|
||||
import type { AuthState, CurrentUser } from "../types/auth";
|
||||
import LiteElement, { html } from "../utils/LiteElement";
|
||||
import { needLogin } from "../utils/auth";
|
||||
|
||||
@localized()
|
||||
class RequestVerify extends LitElement {
|
||||
@property({ type: String })
|
||||
email!: string;
|
||||
|
||||
@state()
|
||||
private isRequesting: boolean = false;
|
||||
|
||||
@state()
|
||||
private requestSuccess: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.requestSuccess) {
|
||||
return html`
|
||||
<div class="text-sm text-gray-400 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-blue-400 hover:text-blue-500"
|
||||
role="button"
|
||||
?disabled=${this.isRequesting}
|
||||
@click=${this.requestVerification}
|
||||
>
|
||||
${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("bt-request-verify", RequestVerify);
|
||||
|
||||
type FormContext = {
|
||||
successMessage?: string;
|
||||
serverError?: string;
|
||||
@ -98,14 +162,19 @@ const machine = createMachine<FormContext, FormEvent, FormTypestate>(
|
||||
@needLogin
|
||||
@localized()
|
||||
export class AccountSettings extends LiteElement {
|
||||
private formStateService = interpret(machine);
|
||||
|
||||
@property({ type: Object })
|
||||
authState?: AuthState;
|
||||
|
||||
private formStateService = interpret(machine);
|
||||
@property({ type: Object })
|
||||
userInfo?: CurrentUser;
|
||||
|
||||
@state()
|
||||
private formState = machine.initialState;
|
||||
|
||||
firstUpdated() {
|
||||
// Enable state machine
|
||||
this.formStateService.subscribe((state) => {
|
||||
this.formState = state;
|
||||
});
|
||||
@ -122,6 +191,7 @@ export class AccountSettings extends LiteElement {
|
||||
this.formState.value === "editingForm" ||
|
||||
this.formState.value === "submittingForm";
|
||||
let successMessage;
|
||||
let verificationMessage;
|
||||
|
||||
if (this.formState.context.successMessage) {
|
||||
successMessage = html`
|
||||
@ -133,6 +203,28 @@ export class AccountSettings extends LiteElement {
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.userInfo) {
|
||||
if (this.userInfo.isVerified) {
|
||||
verificationMessage = html`
|
||||
<sl-tag type="success" size="small"
|
||||
>${msg("verified", {
|
||||
desc: "Status text when user email is verified",
|
||||
})}</sl-tag
|
||||
>
|
||||
`;
|
||||
} else {
|
||||
verificationMessage = html`
|
||||
<sl-tag class="mr-2" type="warning" size="small"
|
||||
>${msg("unverified", {
|
||||
desc: "Status text when user email is not yet verified",
|
||||
})}</sl-tag
|
||||
>
|
||||
|
||||
<bt-request-verify email=${this.userInfo.email}></bt-request-verify>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return html`<div class="grid gap-4">
|
||||
<h1 class="text-xl font-bold">${msg("Account settings")}</h1>
|
||||
|
||||
@ -140,8 +232,11 @@ export class AccountSettings extends LiteElement {
|
||||
|
||||
<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 class="mb-1 text-gray-500">${msg("Email")}</div>
|
||||
<div class="inline-flex items-center">
|
||||
<span class="mr-3">${this.userInfo?.email}</span>
|
||||
${verificationMessage}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${showForm
|
||||
|
@ -42,7 +42,6 @@ export class Alert extends LitElement {
|
||||
`;
|
||||
|
||||
render() {
|
||||
console.log("id:", this.id);
|
||||
return html`
|
||||
<div class="${this.type}" role="alert">
|
||||
<slot></slot>
|
||||
|
@ -1,27 +1,56 @@
|
||||
import { spy, stub } from "sinon";
|
||||
import { spy, stub, mock, restore } from "sinon";
|
||||
import { fixture, expect } from "@open-wc/testing";
|
||||
// import { expect } from "@esm-bundle/chai";
|
||||
|
||||
import { App } from "./index";
|
||||
|
||||
describe("browsertrix-app", () => {
|
||||
beforeEach(() => {
|
||||
stub(App.prototype, "getUserInfo").callsFake(() =>
|
||||
Promise.resolve({
|
||||
email: "test-user@example.com",
|
||||
is_verified: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restore();
|
||||
});
|
||||
|
||||
it("is defined", async () => {
|
||||
const el = await fixture("<browsertrix-app></browsertrix-app>");
|
||||
expect(el).instanceOf(App);
|
||||
});
|
||||
|
||||
it("gets auth state from local storage", async () => {
|
||||
it("sets auth state from local storage", async () => {
|
||||
stub(window.localStorage, "getItem").callsFake((key) => {
|
||||
if (key === "authState")
|
||||
return JSON.stringify({
|
||||
username: "test@example.com",
|
||||
username: "test-auth@example.com",
|
||||
});
|
||||
return null;
|
||||
});
|
||||
const el = (await fixture("<browsertrix-app></browsertrix-app>")) as App;
|
||||
|
||||
expect(el.authState).to.eql({
|
||||
username: "test@example.com",
|
||||
username: "test-auth@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets user info", async () => {
|
||||
stub(window.localStorage, "getItem").callsFake((key) => {
|
||||
if (key === "authState")
|
||||
return JSON.stringify({
|
||||
username: "test-auth@example.com",
|
||||
});
|
||||
return null;
|
||||
});
|
||||
const el = (await fixture("<browsertrix-app></browsertrix-app>")) as App;
|
||||
|
||||
expect(el.userInfo).to.eql({
|
||||
email: "test-user@example.com",
|
||||
isVerified: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -6,6 +6,8 @@ import "./shoelace";
|
||||
import { LocalePicker } from "./components/locale-picker";
|
||||
import { Alert } from "./components/alert";
|
||||
import { AccountSettings } from "./components/account-settings";
|
||||
import { SignUp } from "./pages/sign-up";
|
||||
import { Verify } from "./pages/verify";
|
||||
import { LogInPage } from "./pages/log-in";
|
||||
import { ResetPassword } from "./pages/reset-password";
|
||||
import { MyAccountPage } from "./pages/my-account";
|
||||
@ -14,11 +16,13 @@ import { ArchiveConfigsPage } from "./pages/archive-info-tab";
|
||||
import LiteElement, { html } from "./utils/LiteElement";
|
||||
import APIRouter from "./utils/APIRouter";
|
||||
import type { ViewState, NavigateEvent } from "./utils/APIRouter";
|
||||
import type { AuthState } from "./types/auth";
|
||||
import type { AuthState, CurrentUser } from "./types/auth";
|
||||
import theme from "./theme";
|
||||
|
||||
const ROUTES = {
|
||||
home: "/",
|
||||
signUp: "/sign-up",
|
||||
verify: "/verify?token",
|
||||
login: "/log-in",
|
||||
forgotPassword: "/log-in/forgot-password",
|
||||
resetPassword: "/reset-password?token",
|
||||
@ -35,6 +39,9 @@ export class App extends LiteElement {
|
||||
@state()
|
||||
authState: AuthState | null = null;
|
||||
|
||||
@state()
|
||||
userInfo?: CurrentUser;
|
||||
|
||||
@state()
|
||||
viewState!: ViewState & {
|
||||
aid?: string;
|
||||
@ -80,6 +87,32 @@ export class App extends LiteElement {
|
||||
});
|
||||
}
|
||||
|
||||
async updated(changedProperties: any) {
|
||||
if (changedProperties.has("authState") && this.authState) {
|
||||
const prevAuthState = changedProperties.get("authState");
|
||||
|
||||
if (this.authState.username !== prevAuthState?.username) {
|
||||
this.updateUserInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async updateUserInfo() {
|
||||
try {
|
||||
const data = await this.getUserInfo();
|
||||
|
||||
this.userInfo = {
|
||||
email: data.email,
|
||||
isVerified: data.is_verified,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err?.message === "Unauthorized") {
|
||||
this.clearAuthState();
|
||||
this.navigate(ROUTES.login);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navigate(newViewPath: string) {
|
||||
if (newViewPath.startsWith("http")) {
|
||||
const url = new URL(newViewPath);
|
||||
@ -127,7 +160,7 @@ export class App extends LiteElement {
|
||||
><h1 class="text-base px-2">${msg("Browsertrix Cloud")}</h1></a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<div class="grid grid-flow-col gap-5 items-center">
|
||||
${this.authState
|
||||
? html` <sl-dropdown>
|
||||
<div class="p-2" role="button" slot="trigger">
|
||||
@ -147,7 +180,12 @@ export class App extends LiteElement {
|
||||
>
|
||||
</sl-menu>
|
||||
</sl-dropdown>`
|
||||
: html` <a href="/log-in"> ${msg("Log In")} </a> `}
|
||||
: html`
|
||||
<a href="/log-in"> ${msg("Log In")} </a>
|
||||
<sl-button outline @click="${() => this.navigate("/sign-up")}">
|
||||
<span class="text-white">${msg("Sign up")}</span>
|
||||
</sl-button>
|
||||
`}
|
||||
</div>
|
||||
</nav>
|
||||
`;
|
||||
@ -179,6 +217,21 @@ export class App extends LiteElement {
|
||||
`;
|
||||
|
||||
switch (this.viewState.route) {
|
||||
case "signUp":
|
||||
return html`<btrix-sign-up
|
||||
class="w-full md:bg-gray-100 flex items-center justify-center"
|
||||
@navigate="${this.onNavigateTo}"
|
||||
@logged-in="${this.onLoggedIn}"
|
||||
@log-out="${this.onLogOut}"
|
||||
.authState="${this.authState}"
|
||||
></btrix-sign-up>`;
|
||||
|
||||
case "verify":
|
||||
return html`<btrix-verify
|
||||
class="w-full flex items-center justify-center"
|
||||
token="${this.viewState.params.token}"
|
||||
></btrix-verify>`;
|
||||
|
||||
case "login":
|
||||
case "forgotPassword":
|
||||
return html`<log-in
|
||||
@ -223,6 +276,7 @@ export class App extends LiteElement {
|
||||
@navigate="${this.onNavigateTo}"
|
||||
@need-login="${this.onNeedLogin}"
|
||||
.authState="${this.authState}"
|
||||
.userInfo="${this.userInfo}"
|
||||
></btrix-account-settings>`);
|
||||
|
||||
case "archive-info":
|
||||
@ -241,9 +295,15 @@ export class App extends LiteElement {
|
||||
}
|
||||
}
|
||||
|
||||
onLogOut() {
|
||||
onLogOut(event: CustomEvent<{ redirect?: boolean }>) {
|
||||
const { detail } = event;
|
||||
const redirect = detail.redirect !== false;
|
||||
|
||||
this.clearAuthState();
|
||||
this.navigate("/");
|
||||
|
||||
if (redirect) {
|
||||
this.navigate("/");
|
||||
}
|
||||
}
|
||||
|
||||
onLoggedIn(
|
||||
@ -278,11 +338,17 @@ export class App extends LiteElement {
|
||||
this.authState = null;
|
||||
window.localStorage.setItem("authState", "");
|
||||
}
|
||||
|
||||
getUserInfo() {
|
||||
return this.apiFetch("/users/me", this.authState!);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("bt-alert", Alert);
|
||||
customElements.define("bt-locale-picker", LocalePicker);
|
||||
customElements.define("browsertrix-app", App);
|
||||
customElements.define("btrix-sign-up", SignUp);
|
||||
customElements.define("btrix-verify", Verify);
|
||||
customElements.define("log-in", LogInPage);
|
||||
customElements.define("my-account", MyAccountPage);
|
||||
customElements.define("btrix-archive", ArchivePage);
|
||||
|
192
frontend/src/pages/sign-up.ts
Normal file
192
frontend/src/pages/sign-up.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { state, property, query } from "lit/decorators.js";
|
||||
import { msg, localized } from "@lit/localize";
|
||||
|
||||
import type { AuthState } from "../types/auth";
|
||||
import LiteElement, { html } from "../utils/LiteElement";
|
||||
|
||||
@localized()
|
||||
export class SignUp extends LiteElement {
|
||||
@property({ type: Object })
|
||||
authState?: AuthState;
|
||||
|
||||
@state()
|
||||
isSignUpComplete?: boolean;
|
||||
|
||||
@state()
|
||||
serverError?: string;
|
||||
|
||||
@state()
|
||||
isSubmitting: boolean = false;
|
||||
|
||||
render() {
|
||||
let serverError;
|
||||
|
||||
if (this.serverError) {
|
||||
serverError = html`
|
||||
<div class="mb-5">
|
||||
<bt-alert id="formError" type="danger">${this.serverError}</bt-alert>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<article class="w-full max-w-sm grid gap-5">
|
||||
<main class="md:bg-white md:shadow-xl md:rounded-lg md:px-12 md:py-12">
|
||||
${this.isSignUpComplete
|
||||
? html`
|
||||
<div
|
||||
class="text-2xl font-semibold mb-5 text-primary"
|
||||
role="alert"
|
||||
>
|
||||
${msg("Successfully signed up")}
|
||||
</div>
|
||||
<p class="text-lg">
|
||||
${msg(
|
||||
"Click the link in the verification email we sent you to log in."
|
||||
)}
|
||||
</p>
|
||||
`
|
||||
: html`
|
||||
<h1 class="text-3xl font-semibold mb-5">${msg("Sign up")}</h1>
|
||||
|
||||
<sl-form
|
||||
@sl-submit="${this.onSubmit}"
|
||||
aria-describedby="formError"
|
||||
>
|
||||
<div class="mb-5">
|
||||
<sl-input
|
||||
id="email"
|
||||
name="email"
|
||||
label=${msg("Email")}
|
||||
placeholder=${msg("you@email.com")}
|
||||
autocomplete="username"
|
||||
required
|
||||
>
|
||||
</sl-input>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<sl-input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
label=${msg("Password")}
|
||||
autocomplete="new-password"
|
||||
toggle-password
|
||||
required
|
||||
>
|
||||
</sl-input>
|
||||
</div>
|
||||
|
||||
${serverError}
|
||||
|
||||
<sl-button
|
||||
class="w-full"
|
||||
type="primary"
|
||||
?loading=${this.isSubmitting}
|
||||
submit
|
||||
>${msg("Sign up")}</sl-button
|
||||
>
|
||||
</sl-form>
|
||||
`}
|
||||
</main>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
async onSubmit(event: { detail: { formData: FormData } }) {
|
||||
this.isSubmitting = true;
|
||||
|
||||
if (this.authState) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("log-out", { detail: { redirect: false } })
|
||||
);
|
||||
}
|
||||
|
||||
const { formData } = event.detail;
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const registerParams = {
|
||||
email,
|
||||
password,
|
||||
newArchive: true,
|
||||
};
|
||||
|
||||
const resp = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(registerParams),
|
||||
});
|
||||
|
||||
switch (resp.status) {
|
||||
case 201:
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.is_active) {
|
||||
// Log in right away
|
||||
try {
|
||||
await this.logIn({ email, password });
|
||||
} catch {
|
||||
// Fallback to sign up message
|
||||
this.isSignUpComplete = true;
|
||||
}
|
||||
} else {
|
||||
this.isSignUpComplete = true;
|
||||
}
|
||||
|
||||
break;
|
||||
case 400:
|
||||
case 422:
|
||||
const { detail } = await resp.json();
|
||||
if (detail === "REGISTER_USER_ALREADY_EXISTS") {
|
||||
// Try logging user in
|
||||
try {
|
||||
await this.logIn({ email, password });
|
||||
} catch {
|
||||
this.serverError = msg("Invalid email address or password");
|
||||
}
|
||||
} else {
|
||||
// TODO show validation details
|
||||
this.serverError = msg("Invalid email address or password");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.serverError = msg("Something unexpected went wrong");
|
||||
break;
|
||||
}
|
||||
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
|
||||
private async logIn({
|
||||
email,
|
||||
password,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
const loginParams = new URLSearchParams();
|
||||
loginParams.set("grant_type", "password");
|
||||
loginParams.set("username", email);
|
||||
loginParams.set("password", password);
|
||||
|
||||
const resp = await fetch("/api/auth/jwt/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: loginParams.toString(),
|
||||
});
|
||||
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(resp.statusText);
|
||||
}
|
||||
|
||||
// TODO consolidate with log-in method
|
||||
const data = await resp.json();
|
||||
if (data.token_type === "bearer" && data.access_token) {
|
||||
const auth = "Bearer " + data.access_token;
|
||||
const detail = { auth, username: email };
|
||||
this.dispatchEvent(new CustomEvent("logged-in", { detail }));
|
||||
} else {
|
||||
throw new Error("Unknown authorization type");
|
||||
}
|
||||
}
|
||||
}
|
51
frontend/src/pages/verify.ts
Normal file
51
frontend/src/pages/verify.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { state, property } from "lit/decorators.js";
|
||||
import { msg, localized } from "@lit/localize";
|
||||
|
||||
import LiteElement, { html } from "../utils/LiteElement";
|
||||
|
||||
@localized()
|
||||
export class Verify extends LiteElement {
|
||||
@property({ type: String })
|
||||
token?: string;
|
||||
|
||||
@state()
|
||||
private serverError?: string;
|
||||
|
||||
firstUpdated() {
|
||||
if (this.token) {
|
||||
this.verify();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.serverError) {
|
||||
return html`<bt-alert type="danger">${this.serverError}</bt-alert>`;
|
||||
}
|
||||
return html` <div class="text-4xl"><sl-spinner></sl-spinner></div> `;
|
||||
}
|
||||
|
||||
private async verify() {
|
||||
const resp = await fetch("/api/auth/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: this.token,
|
||||
}),
|
||||
});
|
||||
|
||||
switch (resp.status) {
|
||||
case 200:
|
||||
this.navTo("/log-in");
|
||||
break;
|
||||
case 400:
|
||||
const { detail } = await resp.json();
|
||||
if (detail === "VERIFY_USER_BAD_TOKEN") {
|
||||
this.serverError = msg("This verification email is not valid.");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.serverError = msg("Something unexpected went wrong");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
@ -11,5 +11,6 @@ import "@shoelace-style/shoelace/dist/components/input/input";
|
||||
import "@shoelace-style/shoelace/dist/components/menu/menu";
|
||||
import "@shoelace-style/shoelace/dist/components/menu-item/menu-item";
|
||||
import "@shoelace-style/shoelace/dist/components/select/select";
|
||||
import "@shoelace-style/shoelace/dist/components/spinner/spinner";
|
||||
|
||||
setBasePath("/shoelace");
|
||||
|
@ -6,3 +6,8 @@ export type Auth = {
|
||||
};
|
||||
|
||||
export type AuthState = Auth | null;
|
||||
|
||||
export type CurrentUser = {
|
||||
email: string;
|
||||
isVerified: boolean;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user