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>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
|
||||
const inputClass =
|
||||
"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";
|
||||
|
||||
// "Forgot password?": ask for the user name and have a reset link mailed to the
|
||||
// account's recovery address. The answer is the same whether or not the account
|
||||
// exists — the page never tells a stranger which user names are real.
|
||||
export function ForgotPassword() {
|
||||
const t = useT();
|
||||
const [handle, setHandle] = useState("");
|
||||
const request = useMutation({ mutationFn: () => api.passwordResetRequest(handle.trim()) });
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-24 max-w-sm px-4">
|
||||
<h1 className="mb-2 text-2xl font-semibold">{t("auth.forgotTitle")}</h1>
|
||||
{request.isSuccess ? (
|
||||
<p className="text-sm text-app-fg">{t("auth.forgotSent")}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-6 text-sm text-app-muted">{t("auth.forgotHint")}</p>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (handle.trim()) request.mutate();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={t("auth.handlePlaceholder")}
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="w-full rounded-lg bg-app-accent py-2 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
disabled={request.isPending || !handle.trim()}
|
||||
type="submit"
|
||||
>
|
||||
{request.isPending ? t("auth.sending") : t("auth.changePassword")}
|
||||
</button>
|
||||
{request.error instanceof ApiError && (
|
||||
<p className="text-sm text-red-500">{request.error.message}</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4 text-sm">
|
||||
<Link className="text-app-accent" to="/login">
|
||||
{t("auth.backToSignIn")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,11 @@ export function Login() {
|
||||
</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")}{" "}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
|
||||
const inputClass =
|
||||
"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";
|
||||
|
||||
// The page a reset link opens. The token rides in the URL fragment (#token=…),
|
||||
// which the browser never sends to a server. With 2-step verification on, the
|
||||
// new password is only set once the code has been entered too.
|
||||
export function ResetPassword() {
|
||||
const t = useT();
|
||||
const [token] = useState(() => new URLSearchParams(window.location.hash.slice(1)).get("token") ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [needTotp, setNeedTotp] = useState(false);
|
||||
const [problem, setProblem] = useState("");
|
||||
|
||||
const check = useQuery({
|
||||
queryKey: ["reset-check", token],
|
||||
queryFn: () => api.passwordResetCheck(token),
|
||||
enabled: token !== "",
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: () => api.passwordReset(token, password, needTotp ? totp.trim() : undefined),
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && (error.code === "totp_required" || error.code === "totp_invalid")) {
|
||||
setNeedTotp(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const invalid =
|
||||
token === "" ||
|
||||
check.data?.valid === false ||
|
||||
(reset.error instanceof ApiError && reset.error.code === "invalid_token");
|
||||
|
||||
const submit = () => {
|
||||
setProblem("");
|
||||
if (password.length < 8) return setProblem(t("auth.passwordTooShort"));
|
||||
if (password !== repeat) return setProblem(t("auth.passwordsDiffer"));
|
||||
// Known up front: ask for the code before the first submit, not after it fails.
|
||||
if (check.data?.totp_required && !needTotp) return setNeedTotp(true);
|
||||
reset.mutate();
|
||||
};
|
||||
|
||||
const error = reset.error instanceof ApiError ? reset.error : null;
|
||||
const showError =
|
||||
error && error.code !== "invalid_token" && !(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.resetTitle")}</h1>
|
||||
{reset.isSuccess ? (
|
||||
<p className="text-sm text-app-fg">{t("auth.resetDone")}</p>
|
||||
) : invalid ? (
|
||||
<>
|
||||
<p className="text-sm text-app-fg">{t("auth.resetInvalid")}</p>
|
||||
<p className="mt-4 text-sm">
|
||||
<Link className="text-app-accent" to="/forgot-password">
|
||||
{t("auth.requestNewLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
) : check.isPending ? (
|
||||
<p className="text-sm text-app-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.newPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={needTotp}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.repeatPassword")}
|
||||
value={repeat}
|
||||
onChange={(e) => setRepeat(e.target.value)}
|
||||
disabled={needTotp}
|
||||
/>
|
||||
{needTotp && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-app-muted">{t("auth.resetTotpPrompt")}</p>
|
||||
<input
|
||||
className={`${inputClass} tracking-widest`}
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</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={reset.isPending || (needTotp && !totp.trim())}
|
||||
type="submit"
|
||||
>
|
||||
{needTotp ? t("auth.confirm") : t("auth.changePassword")}
|
||||
</button>
|
||||
{problem && <p className="text-sm text-red-500">{problem}</p>}
|
||||
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
||||
</form>
|
||||
)}
|
||||
<p className="mt-4 text-sm">
|
||||
<Link className="text-app-accent" to="/login">
|
||||
{t("auth.backToSignIn")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { CalendarOut, ReminderPref } from "../../lib/types";
|
||||
@@ -105,11 +106,103 @@ function ProfileSection() {
|
||||
<section className="max-w-4xl space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
||||
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
|
||||
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
|
||||
<RecoveryEmailRow current={data?.recovery_email ?? null} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Whoever controls the recovery address can take the account over with a reset
|
||||
// link, so adding, changing or removing it asks for the current password.
|
||||
function RecoveryEmailRow({ current }: { current: string | null }) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.setRecoveryEmail(password, email.trim() || null),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(["me"], user);
|
||||
setEditing(false);
|
||||
setPassword("");
|
||||
},
|
||||
});
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="border-b border-app-border py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-app-muted">{t("settings.recoveryEmail")}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
<span>{current ?? "—"}</span>
|
||||
<button
|
||||
className="rounded border border-app-border px-2.5 py-1 text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setEmail(current ?? "");
|
||||
setPassword("");
|
||||
save.reset();
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{current ? t("settings.change") : t("settings.add")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-app-faint">{t("settings.recoveryEmailHint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const field =
|
||||
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
|
||||
return (
|
||||
<form
|
||||
className="space-y-2 rounded-lg border border-app-border p-3 text-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-app-muted">{t("settings.recoveryEmailNew")}</span>
|
||||
<input
|
||||
className={field}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@example.com"
|
||||
autoFocus
|
||||
/>
|
||||
{current && (
|
||||
<span className="block text-xs text-app-faint">{t("settings.recoveryEmailRemoveHint")}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-app-muted">{t("settings.currentPassword")}</span>
|
||||
<TypedPasswordInput
|
||||
className={field}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
hint={t("settings.typePassword")}
|
||||
/>
|
||||
</label>
|
||||
{save.error instanceof ApiError && <p className="text-red-500">{save.error.message}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="px-2 text-app-muted" onClick={() => setEditing(false)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
disabled={save.isPending || !password}
|
||||
>
|
||||
{save.isPending ? t("settings.saving") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between border-b border-app-border py-2 text-sm">
|
||||
|
||||
Reference in New Issue
Block a user