// cSpell:words xstate import { localized, msg } from "@lit/localize"; import { assign, createMachine, interpret } from "@xstate/fsm"; import { type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ROUTES } from "@/routes"; import { isApiError } from "@/utils/api"; import type { ViewState } from "@/utils/APIRouter"; import AuthService from "@/utils/AuthService"; import LiteElement, { html } from "@/utils/LiteElement"; 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: "forgotPassword"; context: FormContext; } | { value: "submittingForgotPassword"; context: FormContext; } | { value: "backendInitializing"; context: FormContext; }; const initialContext = {}; const machine = createMachine( { 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() @customElement("btrix-log-in") export class LogInPage extends LiteElement { @property({ type: Object }) viewState!: ViewState; @property({ type: String }) redirectUrl: string = ROUTES.home; private readonly formStateService = interpret(machine); @state() private formState = machine.initialState; private timerId?: number; firstUpdated() { this.formStateService.subscribe((state) => { this.formState = state; }); this.formStateService.start(); void this.checkBackendInitialized(); } disconnectedCallback() { this.formStateService.stop(); window.clearTimeout(this.timerId); super.disconnectedCallback(); } async updated(changedProperties: PropertyValues) { 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` ${msg("Sign in with password")} `; } else { form = this.renderLoginForm(); link = html` ${msg("Forgot your password?")} `; } if (this.formState.context.successMessage) { successMessage = html`
${this.formState.context.successMessage}
`; } return html`
${successMessage}
${form}
${link}
`; } 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`
${this.formState.context.serverError}
`; } return html`
${formError} ${msg("Log In")} ${this.formState.value === "backendInitializing" ? html`
${msg( "Please wait while Browsertrix is initializing", )}
` : ""}
`; } private renderForgotPasswordForm() { let formError; if (this.formState.context.serverError) { formError = html`
${this.formState.context.serverError}
`; } return html`
${formError} ${msg("Request password reset")}
`; } 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 = window.setTimeout(() => { void 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 }); this.dispatchEvent( AuthService.createLoggedInEvent({ ...data, redirectUrl: this.redirectUrl, }), ); // no state update here, since "btrix-logged-in" event // will result in a route change } catch (e) { if (isApiError(e)) { let message = msg("Sorry, invalid username or password"); if (e.statusCode === 429) { message = msg( "Sorry, too many failed login attempts. If this is a valid email, a reset password link has been sent to your email.", ); } this.formStateService.send({ type: "ERROR", detail: { serverError: message, }, }); } 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"), }, }); } } }