431 lines
10 KiB
TypeScript
431 lines
10 KiB
TypeScript
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"),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|