@@ -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`
diff --git a/frontend/src/index.test.ts b/frontend/src/index.test.ts
index 72572679..fd848443 100644
--- a/frontend/src/index.test.ts
+++ b/frontend/src/index.test.ts
@@ -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("");
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("")) 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("")) as App;
+
+ expect(el.userInfo).to.eql({
+ email: "test-user@example.com",
+ isVerified: false,
});
});
});
diff --git a/frontend/src/index.ts b/frontend/src/index.ts
index 2ac02ac2..ffa203d0 100644
--- a/frontend/src/index.ts
+++ b/frontend/src/index.ts
@@ -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 {
>
${msg("Browsertrix Cloud")}
-
+
${this.authState
? html`
@@ -147,7 +180,12 @@ export class App extends LiteElement {
>
`
- : html`
${msg("Log In")} `}
+ : html`
+
${msg("Log In")}
+
this.navigate("/sign-up")}">
+ ${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("Successfully signed up")}
+
+
+ ${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;
+};