v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -8,77 +7,48 @@ import { API_URL } from "../config/api.ts";
|
||||
import {
|
||||
deserializeAuthResponse,
|
||||
type LoginRequest,
|
||||
parseAPIResponse,
|
||||
type RawAuthResponse,
|
||||
} 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";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
} from "../components/form/index.ts";
|
||||
|
||||
type LoginState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "error"; error: string };
|
||||
interface LoginValues {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
type ResetState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "sent" }
|
||||
| { status: "error"; error: string };
|
||||
interface ResetValues {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function UserLogin() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [loginState, setLoginState] = useState<LoginState>({ status: "idle" });
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
const [resetEmail, setResetEmail] = useState("");
|
||||
const [resetState, setResetState] = useState<ResetState>({ status: "idle" });
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoginState({ status: "submitting" });
|
||||
const form = useApiForm<LoginValues>({
|
||||
defaultValues: { username: "", password: "" },
|
||||
});
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/users/login`, {
|
||||
const onSubmit = form.submit(async ({ username, password }) => {
|
||||
const data = await expectOk<RawAuthResponse>(
|
||||
await fetch(`${API_URL}/api/users/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password } satisfies LoginRequest),
|
||||
});
|
||||
|
||||
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
|
||||
|
||||
if (apiResponse.success) {
|
||||
login(deserializeAuthResponse(apiResponse.data));
|
||||
navigate("/");
|
||||
} else {
|
||||
setLoginState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
} catch (err) {
|
||||
setLoginState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetRequest = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!resetEmail.trim()) return;
|
||||
setResetState({ status: "submitting" });
|
||||
try {
|
||||
await fetch(`${API_URL}/api/users/request-password-reset`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: resetEmail.trim() }),
|
||||
});
|
||||
setResetState({ status: "sent" });
|
||||
} catch {
|
||||
setResetState({ status: "error", error: t`Could not connect to server` });
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
login(deserializeAuthResponse(data));
|
||||
navigate("/");
|
||||
});
|
||||
|
||||
return (
|
||||
<PageShell centered>
|
||||
@@ -87,98 +57,38 @@ export function UserLogin() {
|
||||
<Trans>Log in</Trans>
|
||||
</h1>
|
||||
|
||||
{loginState.status === "error" && (
|
||||
<ErrorCard title={t`Login failed`} message={loginState.error} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t`Username`}
|
||||
required
|
||||
disabled={loginState.status === "submitting"}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t`Password`}
|
||||
required
|
||||
disabled={loginState.status === "submitting"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loginState.status === "submitting"}
|
||||
>
|
||||
{loginState.status === "submitting"
|
||||
? <Trans>Logging in…</Trans>
|
||||
: <Trans>Log in</Trans>}
|
||||
</button>
|
||||
</form>
|
||||
<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);
|
||||
setResetState({ status: "idle" });
|
||||
setResetEmail("");
|
||||
}}
|
||||
onClick={() => setShowReset((v) => !v)}
|
||||
>
|
||||
<Trans>Forgot password?</Trans>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{showReset && (
|
||||
<div className="auth-reset-panel">
|
||||
{resetState.status === "sent"
|
||||
? (
|
||||
<p className="auth-reset-sent">
|
||||
<Trans>
|
||||
If that address is registered you'll receive a reset link
|
||||
shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{resetState.status === "error" && (
|
||||
<ErrorCard
|
||||
title={t`Request failed`}
|
||||
message={resetState.error}
|
||||
/>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleResetRequest}
|
||||
className="auth-form auth-reset-form"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t`Your email address`}
|
||||
value={resetEmail}
|
||||
onChange={(e) => setResetEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
disabled={resetState.status === "submitting"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={resetState.status === "submitting" ||
|
||||
!resetEmail.trim()}
|
||||
>
|
||||
{resetState.status === "submitting"
|
||||
? <Trans>Sending…</Trans>
|
||||
: <Trans>Send reset link</Trans>}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showReset && <ResetRequestPanel />}
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Trans>This is a mirage.</Trans>
|
||||
@@ -187,3 +97,49 @@ export function UserLogin() {
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetRequestPanel() {
|
||||
const [sent, setSent] = useState(false);
|
||||
const form = useApiForm<ResetValues>({ defaultValues: { email: "" } });
|
||||
|
||||
const onSubmit = form.submit(async ({ email }) => {
|
||||
await fetch(`${API_URL}/api/users/request-password-reset`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
});
|
||||
setSent(true);
|
||||
});
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="auth-reset-panel">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user