Files
webmail-client/frontend/src/routes/Login.tsx
T
lukasandClaude Opus 5.5 0ddc0cb001 Admin user management, password reset by email, and the Ultimo Email name
Branding: the page titles and app name are now "Ultimo Email".

Admin panel (now two columns with a divider):
- Users: search by login or by a mail account's address or name (results
  only, nothing listed until something is typed). A user opens a window to
  change their quota, block/unblock them, or delete them; block and delete ask
  for confirmation, and an admin cannot do either to their own account.
- Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear
  message and revokes every session; a new user.signed_out event sends the
  user's open tabs to the sign-in screen at once. Any "not signed in" answer
  now does the same, not only /auth/me. Deleting removes mail accounts (sync
  stops), plugin rows (cleared inside the user's RLS context, since plugin
  tables have no FK to users), blobs, the search index, sessions and the user.
- Outgoing system mail (migration 0020): SMTP server, port, security, login,
  password (vault-encrypted, never sent back), From address and the public
  address reset links point to, plus a "Send test" that reports the server's
  error.

Password reset:
- "Forgot password?" on the sign-in page asks for the user name and always
  answers "If this account exists you will receive an email to your recovery
  email address"; the mail goes out after the response, so neither the reply
  nor its timing reveals an account, and it is capped at 3 per hour.
- The emailed link (token in the URL fragment, single use, 30 minutes) opens a
  page to set a new password. With 2-step verification on, the code is checked
  before anything changes; five wrong codes burn the link.
- Settings > Profile: the recovery email can be added, changed or removed,
  confirmed with the current password, which must be typed — autofill and
  paste are refused (TypedPasswordInput).

Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the
logo for a BIMI record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:16:27 +02:00

101 lines
3.8 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 });
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>
<p className="mt-4 text-sm">
<Link className="text-app-accent" to="/forgot-password">
{t("auth.forgotPassword")}
</Link>
</p>
{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>
)}
</div>
);
}