diff --git a/frontend/src/components/account-settings.ts b/frontend/src/components/account-settings.ts index ed06d0f8..9aa1124f 100644 --- a/frontend/src/components/account-settings.ts +++ b/frontend/src/components/account-settings.ts @@ -40,7 +40,9 @@ class RequestVerify extends LitElement { ?disabled=${this.isRequesting} @click=${this.requestVerification} > - ${msg("Resend verification email")} + ${this.isRequesting + ? msg("Sending...") + : msg("Resend verification email")} `; } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index ffa203d0..9d545b76 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -32,6 +32,14 @@ const ROUTES = { "archive-info-tab": "/archive/:aid/:tab", } as const; +/** + * @event navigate + * @event notify + * @event need-login + * @event logged-in + * @event log-out + * @event user-info-change + */ @localized() export class App extends LiteElement { private router: APIRouter = new APIRouter(ROUTES); @@ -230,6 +238,11 @@ export class App extends LiteElement { return html``; case "login": @@ -295,8 +308,8 @@ export class App extends LiteElement { } } - onLogOut(event: CustomEvent<{ redirect?: boolean }>) { - const { detail } = event; + onLogOut(event: CustomEvent<{ redirect?: boolean } | null>) { + const detail = event.detail || {}; const redirect = detail.redirect !== false; this.clearAuthState(); @@ -334,6 +347,61 @@ export class App extends LiteElement { this.navigate(event.detail); } + onUserInfoChange(event: CustomEvent>) { + // @ts-ignore + this.userInfo = { + ...this.userInfo, + ...event.detail, + }; + } + + onNotify( + event: CustomEvent<{ + title?: string; + message?: string; + type?: "success" | "warning" | "danger" | "primary"; + icon?: string; + duration?: number; + }> + ) { + const { + title, + message, + type = "primary", + icon = "info-circle", + duration = 5000, + } = event.detail; + + const escapeHtml = (html: any) => { + const div = document.createElement("div"); + div.textContent = html; + return div.innerHTML; + }; + + const alert = Object.assign(document.createElement("sl-alert"), { + type: type, + closable: true, + duration: duration, + style: [ + "--sl-panel-background-color: var(--sl-color-neutral-1000)", + "--sl-color-neutral-700: var(--sl-color-neutral-0)", + // "--sl-panel-border-width: 0px", + "--sl-spacing-large: var(--sl-spacing-medium)", + ].join(";"), + innerHTML: ` + + + ${title ? `${escapeHtml(title)}` : ""} + ${message ? `
${escapeHtml(message)}
` : ""} +
+ + `, + }); + + document.body.append(alert); + alert.toast(); + } + clearAuthState() { this.authState = null; window.localStorage.setItem("authState", ""); diff --git a/frontend/src/pages/verify.ts b/frontend/src/pages/verify.ts index 403406ae..3cefc654 100644 --- a/frontend/src/pages/verify.ts +++ b/frontend/src/pages/verify.ts @@ -1,10 +1,14 @@ import { state, property } from "lit/decorators.js"; import { msg, localized } from "@lit/localize"; +import { AuthState } from "../types/auth"; import LiteElement, { html } from "../utils/LiteElement"; @localized() export class Verify extends LiteElement { + @property({ type: Object }) + authState?: AuthState; + @property({ type: String }) token?: string; @@ -24,7 +28,7 @@ export class Verify extends LiteElement { return html`
`; } - private async verify() { + private async verify(): Promise { const resp = await fetch("/api/auth/verify", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -33,19 +37,61 @@ export class Verify extends LiteElement { }), }); + const data = await resp.json(); + switch (resp.status) { case 200: - this.navTo("/log-in"); - break; + return this.onVerificationComplete(data); case 400: - const { detail } = await resp.json(); + const { detail } = data; if (detail === "VERIFY_USER_BAD_TOKEN") { this.serverError = msg("This verification email is not valid."); break; } + + if (detail === "VERIFY_USER_ALREADY_VERIFIED") { + return this.onVerificationComplete(data); + } default: this.serverError = msg("Something unexpected went wrong"); break; } } + + private onVerificationComplete(data: { + email: string; + is_verified: boolean; + }) { + const isLoggedIn = Boolean(this.authState); + const shouldLogOut = isLoggedIn && this.authState?.username !== data.email; + + this.dispatchEvent( + new CustomEvent("notify", { + detail: { + title: msg("Email address verified"), + message: + isLoggedIn && !shouldLogOut ? "" : msg("Log in to continue."), + type: "success", + icon: "check2-circle", + duration: 10000, + }, + }) + ); + + if (shouldLogOut) { + this.dispatchEvent(new CustomEvent("log-out")); + } else { + if (isLoggedIn) { + this.dispatchEvent( + new CustomEvent("user-info-change", { + detail: { + isVerified: data.is_verified, + }, + }) + ); + } + + this.navTo("/log-in"); + } + } } diff --git a/frontend/src/shoelace.ts b/frontend/src/shoelace.ts index afbba84d..edc9861f 100644 --- a/frontend/src/shoelace.ts +++ b/frontend/src/shoelace.ts @@ -4,6 +4,7 @@ */ import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js"; import "@shoelace-style/shoelace/dist/themes/light.css"; +import "@shoelace-style/shoelace/dist/components/alert/alert"; import "@shoelace-style/shoelace/dist/components/button/button"; import "@shoelace-style/shoelace/dist/components/form/form"; import "@shoelace-style/shoelace/dist/components/icon/icon"; diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index f3585c6f..aa251d59 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -50,6 +50,11 @@ const theme = css` --sl-input-label-font-size-medium: var(--sl-font-size-small); --sl-input-label-font-size-large: var(--sl-font-size-medium); } + + .sl-toast-stack { + bottom: 0; + top: auto; + } `; export default theme;