Allow users to sign up through UI (#30)

closes #28
This commit is contained in:
sua yoo 2021-11-30 08:57:53 -08:00 committed by GitHub
parent 50e93724fc
commit 3fa85c83f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 453 additions and 15 deletions

View File

@ -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

View File

@ -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>

View File

@ -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,
});
});
});

View File

@ -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);

View 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");
}
}
}

View 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;
}
}
}

View File

@ -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");

View File

@ -6,3 +6,8 @@ export type Auth = {
};
export type AuthState = Auth | null;
export type CurrentUser = {
email: string;
isVerified: boolean;
};