browsertrix/frontend/src/pages/log-in.ts
2023-08-02 17:26:26 -07:00

431 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { state, property } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import { createMachine, interpret, assign } from "@xstate/fsm";
import type { ViewState } from "../utils/APIRouter";
import LiteElement, { html } from "../utils/LiteElement";
import type { LoggedInEventDetail } from "../utils/AuthService";
import AuthService from "../utils/AuthService";
import { DASHBOARD_ROUTE } from "../routes";
type FormContext = {
successMessage?: string;
serverError?: string;
};
type FormSuccessEvent = {
type: "SUCCESS";
detail: {
successMessage?: FormContext["successMessage"];
};
};
type FormErrorEvent = {
type: "ERROR";
detail: {
serverError?: FormContext["serverError"];
};
};
type FormEvent =
| { type: "SHOW_SIGN_IN_WITH_PASSWORD" }
| { type: "SHOW_FORGOT_PASSWORD" }
| { type: "SUBMIT" }
| { type: "BACKEND_INITIALIZED" }
| { type: "BACKEND_NOT_INITIALIZED" }
| FormSuccessEvent
| FormErrorEvent;
type FormTypestate =
| {
value: "signIn";
context: FormContext;
}
| {
value: "signingIn";
context: FormContext;
}
| {
value: "signIn";
context: FormContext;
}
| {
value: "forgotPassword";
context: FormContext;
}
| {
value: "submittingForgotPassword";
context: FormContext;
}
| {
value: "backendInitializing";
context: FormContext;
};
const initialContext = {};
const machine = createMachine<FormContext, FormEvent, FormTypestate>(
{
id: "loginForm",
initial: "signIn",
context: initialContext,
states: {
["signIn"]: {
on: {
SHOW_FORGOT_PASSWORD: {
target: "forgotPassword",
actions: "reset",
},
SUBMIT: {
target: "signingIn",
actions: "reset",
},
BACKEND_NOT_INITIALIZED: {
target: "backendInitializing",
},
},
},
["signingIn"]: {
on: {
SUCCESS: "signedIn",
ERROR: {
target: "signIn",
actions: "setError",
},
},
},
["forgotPassword"]: {
on: {
SHOW_SIGN_IN_WITH_PASSWORD: {
target: "signIn",
actions: "reset",
},
SUBMIT: {
target: "submittingForgotPassword",
actions: "reset",
},
},
},
["submittingForgotPassword"]: {
on: {
SUCCESS: {
target: "signIn",
actions: "setSuccessMessage",
},
ERROR: {
target: "forgotPassword",
actions: "setError",
},
},
},
["backendInitializing"]: {
on: {
BACKEND_INITIALIZED: {
target: "signIn",
actions: "reset",
},
},
},
},
},
{
actions: {
reset: assign(() => initialContext),
setSuccessMessage: assign((context, event) => ({
...context,
...(event as FormSuccessEvent).detail,
})),
setError: assign((context, event) => ({
...context,
...(event as FormErrorEvent).detail,
})),
},
}
);
@localized()
export class LogInPage extends LiteElement {
@property({ type: Object })
viewState!: ViewState;
@property({ type: String })
redirectUrl: string = DASHBOARD_ROUTE;
private formStateService = interpret(machine);
@state()
private formState = machine.initialState;
private timerId?: number;
firstUpdated() {
this.formStateService.subscribe((state) => {
this.formState = state;
});
this.formStateService.start();
this.checkBackendInitialized();
}
disconnectedCallback() {
this.formStateService.stop();
window.clearTimeout(this.timeoutId);
super.disconnectedCallback();
}
async updated(changedProperties: any) {
if (changedProperties.get("viewState")) {
await this.updateComplete;
this.syncFormStateView();
}
}
render() {
let form, link, successMessage;
if (
this.formState.value === "forgotPassword" ||
this.formState.value === "submittingForgotPassword"
) {
form = this.renderForgotPasswordForm();
link = html`
<a
class="text-sm text-gray-400 hover:text-gray-500"
href="/log-in"
@click=${this.navLink}
>${msg("Sign in with password")}</a
>
`;
} else {
form = this.renderLoginForm();
link = html`
<a
class="text-sm text-gray-400 hover:text-gray-500"
href="/log-in/forgot-password"
@click=${this.navLink}
>${msg("Forgot your password?")}</a
>
`;
}
if (this.formState.context.successMessage) {
successMessage = html`
<div>
<btrix-alert variant="success"
>${this.formState.context.successMessage}</btrix-alert
>
</div>
`;
}
return html`
<article class="w-full max-w-sm grid gap-5">
${successMessage}
<main class="md:bg-white md:shadow-xl md:rounded-lg p-12">
<div>${form}</div>
</main>
<footer class="text-center">${link}</footer>
</article>
`;
}
private syncFormStateView() {
const route = this.viewState.route;
if (route === "login") {
this.formStateService.send("SHOW_SIGN_IN_WITH_PASSWORD");
} else if (route === "forgotPassword") {
this.formStateService.send("SHOW_FORGOT_PASSWORD");
}
}
private renderLoginForm() {
let formError;
if (this.formState.context.serverError) {
formError = html`
<div class="mb-5">
<btrix-alert id="formError" variant="danger"
>${this.formState.context.serverError}</btrix-alert
>
</div>
`;
}
return html`
<form @submit=${this.onSubmitLogIn} aria-describedby="formError">
<div class="mb-5">
<btrix-input
id="email"
name="username"
label=${msg("Email")}
type="email"
autocomplete="username"
required
>
</btrix-input>
</div>
<div class="mb-5">
<btrix-input
id="password"
name="password"
label=${msg("Password")}
type="password"
autocomplete="current-password"
passwordToggle
required
>
</btrix-input>
</div>
${formError}
<sl-button
class="w-full"
variant="primary"
?loading=${this.formState.value === "signingIn"}
?disabled=${this.formState.value === "backendInitializing"}
type="submit"
>${msg("Log in")}</sl-button
>
${this.formState.value === "backendInitializing"
? html` <div class="mt-3">
<btrix-alert variant="warning" class="text-center"
>${msg(
"Please wait while Browsertrix Cloud is initializing"
)}</btrix-alert
>
</div>`
: ""}
</form>
`;
}
private renderForgotPasswordForm() {
let formError;
if (this.formState.context.serverError) {
formError = html`
<div class="mb-5">
<btrix-alert id="formError" variant="danger"
>${this.formState.context.serverError}</btrix-alert
>
</div>
`;
}
return html`
<form @submit=${this.onSubmitResetPassword} aria-describedby="formError">
<div class="mb-5">
<btrix-input
id="email"
name="email"
type="email"
label="${msg("Your email address")}"
autocomplete="username"
required
>
</btrix-input>
</div>
${formError}
<sl-button
class="w-full"
variant="primary"
?loading=${this.formState.value === "submittingForgotPassword"}
type="submit"
>${msg("Request password reset")}</sl-button
>
</form>
`;
}
async checkBackendInitialized() {
const resp = await fetch("/api/settings");
if (resp.status === 200) {
this.formStateService.send("BACKEND_INITIALIZED");
} else {
this.formStateService.send("BACKEND_NOT_INITIALIZED");
this.timerId = setTimeout(() => this.checkBackendInitialized(), 5000);
}
}
async onSubmitLogIn(event: SubmitEvent) {
event.preventDefault();
this.formStateService.send("SUBMIT");
const formData = new FormData(event.target as HTMLFormElement);
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const data = await AuthService.login({ email: username, password });
(data as LoggedInEventDetail).redirectUrl = this.redirectUrl;
this.dispatchEvent(AuthService.createLoggedInEvent(data));
// no state update here, since "logged-in" event
// will result in a route change
} catch (e: any) {
if (e.isApiError) {
// TODO check error details
this.formStateService.send({
type: "ERROR",
detail: {
serverError: msg("Sorry, invalid username or password"),
},
});
} else {
this.formStateService.send({
type: "ERROR",
detail: {
serverError: msg("Something went wrong, couldn't sign you in"),
},
});
}
}
}
async onSubmitResetPassword(event: SubmitEvent) {
event.preventDefault();
this.formStateService.send("SUBMIT");
const formData = new FormData(event.target as HTMLFormElement);
const email = formData.get("email") as string;
const resp = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
if (resp.status === 202) {
this.formStateService.send({
type: "SUCCESS",
detail: {
successMessage: msg(
"Successfully received your request. You will receive an email to reset your password if your email is found in our system."
),
},
});
} else if (resp.status === 422) {
this.formStateService.send({
type: "ERROR",
detail: {
serverError: msg("That email is not a valid email address"),
},
});
} else {
this.formStateService.send({
type: "ERROR",
detail: {
serverError: msg("Something unexpected went wrong"),
},
});
}
}
}