diff --git a/frontend/src/components/account-settings.ts b/frontend/src/components/account-settings.ts index 2336e94f..ed06d0f8 100644 --- a/frontend/src/components/account-settings.ts +++ b/frontend/src/components/account-settings.ts @@ -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` +
+ ${msg("Sent", { + desc: "Status message after sending verification email", + })} +
+ `; + } + + return html` + + ${msg("Resend verification email")} + + `; + } + + 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( @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` + ${msg("verified", { + desc: "Status text when user email is verified", + })} + `; + } else { + verificationMessage = html` + ${msg("unverified", { + desc: "Status text when user email is not yet verified", + })} + + + `; + } + } + return html`

${msg("Account settings")}

@@ -140,8 +232,11 @@ export class AccountSettings extends LiteElement {
-
Email
-
${this.authState!.username}
+
${msg("Email")}
+
+ ${this.userInfo?.email} + ${verificationMessage} +
${showForm diff --git a/frontend/src/components/alert.ts b/frontend/src/components/alert.ts index 93f7bde0..0d299422 100644 --- a/frontend/src/components/alert.ts +++ b/frontend/src/components/alert.ts @@ -42,7 +42,6 @@ export class Alert extends LitElement { `; render() { - console.log("id:", this.id); return html` -
+
${this.authState ? html`
@@ -147,7 +180,12 @@ export class App extends LiteElement { > ` - : html` ${msg("Log In")} `} + : html` + ${msg("Log In")} + + ${msg("Sign up")} + + `}
`; @@ -179,6 +217,21 @@ export class App extends LiteElement { `; switch (this.viewState.route) { + case "signUp": + return html``; + + case "verify": + return html``; + case "login": case "forgotPassword": return html``); 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); diff --git a/frontend/src/pages/sign-up.ts b/frontend/src/pages/sign-up.ts new file mode 100644 index 00000000..3c78bf8a --- /dev/null +++ b/frontend/src/pages/sign-up.ts @@ -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` +
+ ${this.serverError} +
+ `; + } + + return html` +
+
+ ${this.isSignUpComplete + ? html` + +

+ ${msg( + "Click the link in the verification email we sent you to log in." + )} +

+ ` + : html` +

${msg("Sign up")}

+ + +
+ + +
+
+ + +
+ + ${serverError} + + ${msg("Sign up")} +
+ `} +
+
+ `; + } + + 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"); + } + } +} diff --git a/frontend/src/pages/verify.ts b/frontend/src/pages/verify.ts new file mode 100644 index 00000000..403406ae --- /dev/null +++ b/frontend/src/pages/verify.ts @@ -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`${this.serverError}`; + } + return html`
`; + } + + 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; + } + } +} diff --git a/frontend/src/shoelace.ts b/frontend/src/shoelace.ts index f2a4623a..afbba84d 100644 --- a/frontend/src/shoelace.ts +++ b/frontend/src/shoelace.ts @@ -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"); diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 5b416c35..6c783697 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -6,3 +6,8 @@ export type Auth = { }; export type AuthState = Auth | null; + +export type CurrentUser = { + email: string; + isVerified: boolean; +};