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" } | FormSuccessEvent | FormErrorEvent; type FormTypestate = | { value: "signIn"; context: FormContext; } | { value: "signingIn"; context: FormContext; } | { value: "signIn"; context: FormContext; } | { value: "forgotPassword"; context: FormContext; } | { value: "submittingForgotPassword"; 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", }, }, }, ["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: "setSucessMessage", }, ERROR: { target: "forgotPassword", actions: "setError", }, }, }, }, }, { actions: { reset: assign(() => initialContext), setSucessMessage: 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; firstUpdated() { this.formStateService.subscribe((state) => { this.formState = state; }); this.formStateService.start(); } disconnectedCallback() { this.formStateService.stop(); } 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` ${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")}
`; } private renderForgotPasswordForm() { let formError; if (this.formState.context.serverError) { formError = html`
${this.formState.context.serverError}
`; } return html`
${formError} ${msg("Request password reset")}
`; } async onSubmitLogIn(event: { detail: { formData: FormData } }) { this.formStateService.send("SUBMIT"); const { formData } = event.detail; 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: { detail: { formData: FormData } }) { this.formStateService.send("SUBMIT"); const { formData } = event.detail; 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"), }, }); } } }