Files
webmail-client/frontend/src/routes/Login.tsx
T

96 lines
3.6 KiB
TypeScript
Raw Normal View History

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2026-07-17 23:20:34 +02:00
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
export function Login() {
const t = useT();
const [handle, setHandle] = useState("");
const [password, setPassword] = useState("");
const [totp, setTotp] = useState("");
const [needTotp, setNeedTotp] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
// Only offer the signup link when the server would actually accept one.
const registration = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
2026-07-17 23:20:34 +02:00
const mutation = useMutation({
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined),
onSuccess: (user) => {
queryClient.setQueryData(["me"], user);
navigate("/mail");
},
onError: (error) => {
if (error instanceof ApiError && (error.code === "totp_required" || error.code === "totp_invalid")) {
setNeedTotp(true);
}
},
});
const error = mutation.error instanceof ApiError ? mutation.error : null;
// Don't show the "code required" error as a failure once we've revealed the field.
const showError = error && !(needTotp && error.code === "totp_required");
return (
<div className="mx-auto mt-24 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-semibold">{t("auth.signInTitle")}</h1>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
>
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
placeholder={t("auth.handlePlaceholder")}
value={handle}
onChange={(e) => setHandle(e.target.value)}
disabled={needTotp}
autoFocus
/>
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
type="password"
placeholder={t("auth.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={needTotp}
/>
{needTotp && (
<div className="space-y-1">
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none tracking-widest"
placeholder="123456"
inputMode="numeric"
autoComplete="one-time-code"
value={totp}
onChange={(e) => setTotp(e.target.value)}
autoFocus
/>
<p className="text-xs text-app-muted">{t("auth.totpPrompt")}</p>
</div>
)}
<button
className="w-full rounded-lg bg-app-accent py-2 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={mutation.isPending}
type="submit"
>
{mutation.isPending ? t("auth.signingIn") : t("auth.signIn")}
</button>
{showError && <p className="text-sm text-red-500">{error.message}</p>}
</form>
{registration.data?.open && (
<p className="mt-4 text-sm text-app-muted">
{t("auth.noAccount")}{" "}
<Link className="text-app-accent" to="/register">
{t("auth.register")}
</Link>
</p>
)}
2026-07-17 23:20:34 +02:00
</div>
);
}