164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
/**
|
|
* Renders the sign-in page, which also requests password reset links.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { useNavigate } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
|
|
import { API_URL } from "../config/api.ts";
|
|
import { deserializeAuthResponse } from "../utils/shared/deserialize.ts";
|
|
import type { AuthResponse, LoginUserRequest } from "../../shared/user.ts";
|
|
import type { Wire } from "../../shared/api.ts";
|
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
|
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
|
import { PageShell } from "../components/layout/PageShell.tsx";
|
|
import { expectOk } from "../utils/shared/api.ts";
|
|
import { FormError } from "../components/form/FormError.tsx";
|
|
import { FormProvider } from "react-hook-form";
|
|
import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
|
import { TextField } from "../components/form/TextField.tsx";
|
|
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
|
|
|
interface LoginValues {
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
interface ResetValues {
|
|
email: string;
|
|
}
|
|
|
|
/**
|
|
* Renders `/login`, for the signed out. A sign-in lands on `/`, and "Forgot
|
|
* password?" unfolds the reset request form below.
|
|
*/
|
|
export function UserLogin(): React.JSX.Element {
|
|
useDocumentTitle(t`Log in`);
|
|
const navigate = useNavigate();
|
|
const { login } = useAuth();
|
|
const [showReset, setShowReset] = useState(false);
|
|
const cardRef = useHeightTransition<HTMLDivElement>(showReset);
|
|
|
|
const form = useApiForm<LoginValues>({
|
|
defaultValues: { username: "", password: "" },
|
|
});
|
|
|
|
const onSubmit = form.submit(async ({ username, password }) => {
|
|
const data = await expectOk<Wire<AuthResponse>>(
|
|
await fetch(`${API_URL}/api/sessions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password } satisfies LoginUserRequest),
|
|
}),
|
|
);
|
|
login(deserializeAuthResponse(data));
|
|
navigate("/");
|
|
});
|
|
|
|
return (
|
|
<PageShell centered>
|
|
<div className="auth-card" ref={cardRef}>
|
|
<h1 className="auth-card-title">
|
|
<Trans>Log in</Trans>
|
|
</h1>
|
|
|
|
<FormProvider {...form}>
|
|
<form onSubmit={onSubmit} className="form">
|
|
<FormError title={t`Login failed`} />
|
|
<TextField<LoginValues>
|
|
name="username"
|
|
placeholder={t`Username`}
|
|
autoFocus
|
|
rules={{ required: true }}
|
|
/>
|
|
<TextField<LoginValues>
|
|
name="password"
|
|
type="password"
|
|
placeholder={t`Password`}
|
|
rules={{ required: true }}
|
|
/>
|
|
<SubmitButton pendingLabel={<Trans>Logging in…</Trans>}>
|
|
<Trans>Log in</Trans>
|
|
</SubmitButton>
|
|
</form>
|
|
</FormProvider>
|
|
|
|
<p className="auth-card-footer">
|
|
<button
|
|
type="button"
|
|
className="auth-link-btn"
|
|
onClick={() => setShowReset((v) => !v)}
|
|
>
|
|
<Trans>Forgot password?</Trans>
|
|
</button>
|
|
</p>
|
|
|
|
{showReset && <ResetRequestPanel />}
|
|
|
|
<p className="auth-card-footer">
|
|
<Trans>This is a mirage.</Trans>
|
|
</p>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the form that asks the server to email a password reset link, which
|
|
* leads to `/reset-password`.
|
|
*/
|
|
function ResetRequestPanel() {
|
|
const [sent, setSent] = useState(false);
|
|
const form = useApiForm<ResetValues>({ defaultValues: { email: "" } });
|
|
|
|
const onSubmit = form.submit(async ({ email }) => {
|
|
// The server answers 200 whether or not the address is known, so as not
|
|
// to reveal which ones are. An error here means it failed.
|
|
await expectOk(
|
|
await fetch(`${API_URL}/api/password-resets`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: email.trim() }),
|
|
}),
|
|
);
|
|
setSent(true);
|
|
});
|
|
|
|
if (sent) {
|
|
return (
|
|
<div className="auth-reset-panel animate-in">
|
|
<p className="auth-reset-sent">
|
|
<Trans>
|
|
If that address is registered you'll receive a reset link shortly.
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="auth-reset-panel animate-in">
|
|
<FormProvider {...form}>
|
|
<form onSubmit={onSubmit} className="form auth-reset-form">
|
|
<FormError title={t`Request failed`} />
|
|
<TextField<ResetValues>
|
|
name="email"
|
|
type="email"
|
|
placeholder={t`Your email address`}
|
|
autoFocus
|
|
rules={{ required: true }}
|
|
/>
|
|
<SubmitButton pendingLabel={<Trans>Sending…</Trans>}>
|
|
<Trans>Send reset link</Trans>
|
|
</SubmitButton>
|
|
</form>
|
|
</FormProvider>
|
|
</div>
|
|
);
|
|
}
|