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>
101 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|