166 lines
5.0 KiB
TypeScript
166 lines
5.0 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import type { SubmitEvent } from "react";
|
||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||
import { t } from "@lingui/core/macro"
|
||
import { Trans } from "@lingui/react/macro";
|
||
|
||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||
import {
|
||
deserializeAuthResponse,
|
||
parseAPIResponse,
|
||
type RawAuthResponse,
|
||
type RegisterRequest,
|
||
} from "../model.ts";
|
||
import { useAuth } from "../hooks/useAuth.ts";
|
||
import { PageShell } from "../components/PageShell.tsx";
|
||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||
|
||
type TokenState =
|
||
| { status: "checking" }
|
||
| { status: "invalid" }
|
||
| { status: "valid" };
|
||
|
||
type FormState =
|
||
| { status: "idle" }
|
||
| { status: "submitting" }
|
||
| { status: "error"; error: string };
|
||
|
||
export function UserRegister() {
|
||
const navigate = useNavigate();
|
||
const { login } = useAuth();
|
||
const [searchParams] = useSearchParams();
|
||
const token = searchParams.get("token") ?? "";
|
||
|
||
const [tokenState, setTokenState] = useState<TokenState>(() =>
|
||
token ? { status: "checking" } : { status: "invalid" }
|
||
);
|
||
const [formState, setFormState] = useState<FormState>({ status: "idle" });
|
||
const [prevToken, setPrevToken] = useState(token);
|
||
|
||
if (prevToken !== token) {
|
||
setPrevToken(token);
|
||
setTokenState(token ? { status: "checking" } : { status: "invalid" });
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!token) return;
|
||
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
|
||
.then((r) => {
|
||
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
|
||
})
|
||
.catch(() => setTokenState({ status: "invalid" }));
|
||
}, [token]);
|
||
|
||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||
e.preventDefault();
|
||
setFormState({ status: "submitting" });
|
||
|
||
const formData = new FormData(e.currentTarget);
|
||
const username = formData.get("username") as string;
|
||
const password = formData.get("password") as string;
|
||
const email = formData.get("email") as string;
|
||
|
||
try {
|
||
const res = await fetch(`${API_URL}/api/users/register`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(
|
||
{
|
||
username,
|
||
password,
|
||
inviteToken: token,
|
||
email,
|
||
} satisfies RegisterRequest,
|
||
),
|
||
});
|
||
|
||
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
|
||
|
||
if (apiResponse.success) {
|
||
login(deserializeAuthResponse(apiResponse.data));
|
||
navigate("/");
|
||
} else {
|
||
setFormState({ status: "error", error: apiResponse.error.message });
|
||
}
|
||
} catch (err) {
|
||
setFormState({ status: "error", error: friendlyFetchError(err) });
|
||
}
|
||
};
|
||
|
||
if (tokenState.status === "checking") {
|
||
return (
|
||
<PageShell centered>
|
||
<p className="page-loading"><Trans>Checking invite…</Trans></p>
|
||
</PageShell>
|
||
);
|
||
}
|
||
|
||
if (tokenState.status === "invalid") {
|
||
return (
|
||
<PageShell centered>
|
||
<div className="page-error-wrap">
|
||
<ErrorCard
|
||
title={t`Invalid invite`}
|
||
message={t`This invite link is missing, expired, or already used.`}
|
||
/>
|
||
</div>
|
||
</PageShell>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<PageShell centered>
|
||
<div className="auth-card">
|
||
<h1 className="auth-card-title"><Trans>Register</Trans></h1>
|
||
|
||
{formState.status === "error" && (
|
||
<ErrorCard title={t`Registration failed`} message={formState.error} />
|
||
)}
|
||
|
||
<form onSubmit={handleSubmit} className="auth-form">
|
||
<input
|
||
name="username"
|
||
type="text"
|
||
placeholder={t`Username`}
|
||
required
|
||
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
|
||
title={t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
|
||
disabled={formState.status === "submitting"}
|
||
autoFocus
|
||
/>
|
||
<input
|
||
name="email"
|
||
type="email"
|
||
placeholder={t`Email address`}
|
||
required
|
||
disabled={formState.status === "submitting"}
|
||
/>
|
||
<input
|
||
name="password"
|
||
type="password"
|
||
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
|
||
required
|
||
minLength={VALIDATION.PASSWORD_MIN}
|
||
maxLength={VALIDATION.PASSWORD_MAX}
|
||
disabled={formState.status === "submitting"}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary"
|
||
disabled={formState.status === "submitting"}
|
||
>
|
||
{formState.status === "submitting"
|
||
? <Trans>Registering…</Trans>
|
||
: <Trans>Register</Trans>}
|
||
</button>
|
||
</form>
|
||
|
||
<p className="auth-card-footer">
|
||
<Trans>Already have an account? <Link to="/login">Log in</Link></Trans>
|
||
</p>
|
||
</div>
|
||
</PageShell>
|
||
);
|
||
}
|