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:
@@ -6,9 +6,11 @@ import { useMe } from "./lib/auth";
|
||||
import { connectWs } from "./lib/ws";
|
||||
import { PluginPage } from "./plugins/PluginPage";
|
||||
import { ComposeWindow } from "./routes/ComposeWindow";
|
||||
import { ForgotPassword } from "./routes/ForgotPassword";
|
||||
import { Login } from "./routes/Login";
|
||||
import { MailLayout } from "./routes/MailLayout";
|
||||
import { Register } from "./routes/Register";
|
||||
import { ResetPassword } from "./routes/ResetPassword";
|
||||
import { Settings } from "./routes/settings/Settings";
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
@@ -35,6 +37,8 @@ export function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route
|
||||
path="/mail/*"
|
||||
element={
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { useMe } from "../lib/auth";
|
||||
import type { AdminMail, AdminUser } from "../lib/types";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
@@ -28,11 +29,24 @@ export function AdminApp() {
|
||||
<header className="border-b border-app-border px-6 py-3">
|
||||
<h1 className="font-semibold">{t("admin.title")}</h1>
|
||||
</header>
|
||||
<main className="mx-auto max-w-2xl px-6 py-10">
|
||||
{/* Two columns once there is room: instance settings and users on the
|
||||
left, outgoing system mail on the right. Signed out, it stays narrow. */}
|
||||
<main className={`mx-auto px-6 py-10 ${me.data?.is_admin ? "max-w-6xl" : "max-w-2xl"}`}>
|
||||
{me.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
|
||||
{!me.isPending && !me.data && <SignIn />}
|
||||
{me.data && !me.data.is_admin && <NotAdmin handle={me.data.handle} />}
|
||||
{me.data?.is_admin && <Panel handle={me.data.handle} />}
|
||||
{me.data?.is_admin && (
|
||||
<div className="grid gap-y-10 lg:grid-cols-2">
|
||||
<div className="min-w-0 lg:pr-12">
|
||||
<Panel handle={me.data.handle} />
|
||||
<Users selfId={me.data.id} />
|
||||
</div>
|
||||
{/* The divider runs the full height of the taller column. */}
|
||||
<div className="min-w-0 lg:border-l lg:border-app-border lg:pl-12">
|
||||
<SystemMail />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@@ -235,3 +249,487 @@ function Panel({ handle }: { handle: string }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type MailDraft = Omit<AdminMail, "has_password" | "configured">;
|
||||
|
||||
/** The SMTP account the server sends password-reset links from. */
|
||||
function SystemMail() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const mail = useQuery({ queryKey: ["adminMail"], queryFn: api.adminMail });
|
||||
// Edits are a draft until Save; null means "show what the server has".
|
||||
const [draft, setDraft] = useState<MailDraft | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [testTo, setTestTo] = useState("");
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Parameters<typeof api.saveAdminMail>[0]) => api.saveAdminMail(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["adminMail"], data);
|
||||
setDraft(null);
|
||||
setPassword("");
|
||||
},
|
||||
});
|
||||
const test = useMutation({ mutationFn: () => api.testAdminMail(testTo.trim()) });
|
||||
|
||||
if (!mail.data) {
|
||||
return mail.error instanceof ApiError ? (
|
||||
<p className="text-sm text-red-500">{mail.error.message}</p>
|
||||
) : null;
|
||||
}
|
||||
const server = mail.data;
|
||||
const value: MailDraft = draft ?? {
|
||||
smtp_host: server.smtp_host,
|
||||
smtp_port: server.smtp_port,
|
||||
smtp_security: server.smtp_security,
|
||||
smtp_username: server.smtp_username,
|
||||
smtp_from: server.smtp_from,
|
||||
public_url: server.public_url,
|
||||
};
|
||||
const set = (patch: Partial<MailDraft>) => setDraft({ ...value, ...patch });
|
||||
const dirty = draft !== null || password !== "";
|
||||
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";
|
||||
const label = "block space-y-1 text-sm";
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("admin.mail")}</h2>
|
||||
<p className="text-xs text-app-faint">{t("admin.mailHelp")}</p>
|
||||
<p className={`text-sm ${server.configured ? "text-emerald-600" : "text-amber-600"}`}>
|
||||
{server.configured ? t("admin.mailOn") : t("admin.mailOff")}
|
||||
</p>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({ ...value, ...(password ? { smtp_password: password } : {}) });
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_6rem_8rem] gap-2">
|
||||
<label className={label}>
|
||||
<span className="text-app-muted">{t("admin.smtpHost")}</span>
|
||||
<input
|
||||
className={field}
|
||||
value={value.smtp_host}
|
||||
placeholder="smtp.example.com"
|
||||
onChange={(e) => set({ smtp_host: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
<span className="text-app-muted">{t("admin.smtpPort")}</span>
|
||||
<input
|
||||
className={field}
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={value.smtp_port}
|
||||
onChange={(e) => set({ smtp_port: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
<span className="text-app-muted">{t("admin.smtpSecurity")}</span>
|
||||
<select
|
||||
className={field}
|
||||
value={value.smtp_security}
|
||||
onChange={(e) => {
|
||||
const security = e.target.value as MailDraft["smtp_security"];
|
||||
// Follow the usual port for the mode, unless a custom one was typed.
|
||||
const usual = { starttls: 587, ssl: 465, none: 25 };
|
||||
const port = Object.values(usual).includes(value.smtp_port) ? usual[security] : value.smtp_port;
|
||||
set({ smtp_security: security, smtp_port: port });
|
||||
}}
|
||||
>
|
||||
<option value="starttls">{t("admin.smtpStarttls")}</option>
|
||||
<option value="ssl">{t("admin.smtpSsl")}</option>
|
||||
<option value="none">{t("admin.smtpNone")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className={label}>
|
||||
<span className="text-app-muted">{t("admin.smtpUsername")}</span>
|
||||
<input
|
||||
className={field}
|
||||
autoComplete="off"
|
||||
value={value.smtp_username}
|
||||
placeholder="postmaster@example.com"
|
||||
onChange={(e) => set({ smtp_username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
<span className="text-app-muted">{t("admin.smtpPassword")}</span>
|
||||
<input
|
||||
className={field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
placeholder={server.has_password ? t("admin.smtpPasswordKept") : ""}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className={label}>
|
||||
<span className="text-app-muted">{t("admin.smtpFrom")}</span>
|
||||
<input
|
||||
className={field}
|
||||
type="email"
|
||||
value={value.smtp_from}
|
||||
placeholder="postmaster@example.com"
|
||||
onChange={(e) => set({ smtp_from: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
<span className="text-app-muted">{t("admin.publicUrl")}</span>
|
||||
<input
|
||||
className={field}
|
||||
value={value.public_url}
|
||||
placeholder="https://mail.example.com"
|
||||
onChange={(e) => set({ public_url: e.target.value })}
|
||||
/>
|
||||
<span className="block text-xs text-app-faint">{t("admin.publicUrlHint")}</span>
|
||||
</label>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{server.has_password ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-app-muted underline hover:text-app-fg disabled:opacity-50"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate({ smtp_password: "" })}
|
||||
>
|
||||
{t("admin.smtpPasswordRemove")}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-app-accent px-3 py-1 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
disabled={!dirty || save.isPending}
|
||||
>
|
||||
{save.isPending ? t("admin.saving") : t("admin.save")}
|
||||
</button>
|
||||
</div>
|
||||
{save.error instanceof ApiError && <p className="text-xs text-red-500">{save.error.message}</p>}
|
||||
</form>
|
||||
|
||||
<form
|
||||
className="flex items-center gap-2 border-t border-app-border pt-3 text-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
test.mutate();
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 text-app-muted">{t("admin.testTo")}</span>
|
||||
<input
|
||||
className={field}
|
||||
type="email"
|
||||
value={testTo}
|
||||
placeholder="you@example.com"
|
||||
onChange={(e) => {
|
||||
setTestTo(e.target.value);
|
||||
test.reset();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
|
||||
disabled={!server.configured || !testTo.trim() || test.isPending || dirty}
|
||||
>
|
||||
{test.isPending ? t("auth.sending") : t("admin.sendTest")}
|
||||
</button>
|
||||
</form>
|
||||
{test.isSuccess && <p className="text-xs text-emerald-600">{t("admin.testSent")}</p>}
|
||||
{test.error instanceof ApiError && <p className="text-xs text-red-500">{test.error.message}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(2))} GiB`;
|
||||
return `${Number((bytes / 1024 ** 2).toFixed(1))} MiB`;
|
||||
}
|
||||
|
||||
/** Find a user by login, mail address or name, and open them to manage. */
|
||||
function Users({ selfId }: { selfId: string }) {
|
||||
const t = useT();
|
||||
const [term, setTerm] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
// Search as you type, but not on every keystroke.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setQuery(term.trim()), 250);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [term]);
|
||||
|
||||
// Only search results are listed: nothing until something is typed.
|
||||
const users = useQuery({
|
||||
queryKey: ["adminUsers", query],
|
||||
queryFn: () => api.adminUsers(query),
|
||||
enabled: query !== "",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const results = query ? users.data : undefined;
|
||||
const open = results?.find((u) => u.id === openId) ?? null;
|
||||
|
||||
return (
|
||||
<section className="mt-10 space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("admin.users")}</h2>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="search"
|
||||
placeholder={t("admin.userSearch")}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
{users.error instanceof ApiError && (
|
||||
<p className="text-sm text-red-500">{users.error.message}</p>
|
||||
)}
|
||||
{results && results.length === 0 && (
|
||||
<p className="text-sm text-app-muted">{t("admin.noUsers")}</p>
|
||||
)}
|
||||
{results && results.length > 0 && (
|
||||
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
||||
{results.map((u) => (
|
||||
<li key={u.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(u.id)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-app-hover"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{u.handle}</span>
|
||||
<Badges user={u} self={u.id === selfId} />
|
||||
</span>
|
||||
<span className="block truncate text-xs text-app-muted">
|
||||
{u.accounts.length ? u.accounts.join(", ") : t("admin.noMailAccounts")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-app-muted">
|
||||
{t("admin.usage", {
|
||||
used: formatBytes(u.used_bytes),
|
||||
quota: formatBytes(u.quota_bytes),
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{open && <UserDialog user={open} self={open.id === selfId} onClose={() => setOpenId(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Badges({ user, self }: { user: AdminUser; self: boolean }) {
|
||||
const t = useT();
|
||||
const badge = "shrink-0 rounded px-1.5 text-[11px] font-medium";
|
||||
return (
|
||||
<>
|
||||
{self && <span className={`${badge} bg-app-hover text-app-muted`}>{t("admin.you")}</span>}
|
||||
{user.is_admin && (
|
||||
<span className={`${badge} bg-app-accent/15 text-app-accent`}>{t("admin.adminBadge")}</span>
|
||||
)}
|
||||
{user.blocked_at && (
|
||||
<span className={`${badge} bg-red-500/15 text-red-500`}>{t("admin.blockedBadge")}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One user: change their quota, block or unblock them, or delete them. */
|
||||
function UserDialog({
|
||||
user,
|
||||
self,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
self: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<null | "block" | "delete">(null);
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
|
||||
const update = useMutation({
|
||||
mutationFn: (body: { quota_bytes?: number; blocked?: boolean }) =>
|
||||
api.updateAdminUser(user.id, body),
|
||||
onSuccess: () => {
|
||||
setQuotaDraft(null);
|
||||
setConfirm(null);
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api.deleteAdminUser(user.id),
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (confirm) setConfirm(null);
|
||||
else onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [confirm, onClose]);
|
||||
|
||||
const serverGib = user.quota_bytes / GIB;
|
||||
const quotaValue = quotaDraft ?? String(Number(serverGib.toFixed(3)));
|
||||
const quotaGib = Number(quotaValue);
|
||||
const quotaValid = Number.isFinite(quotaGib) && quotaGib > 0;
|
||||
const quotaDirty = quotaDraft !== null && quotaGib !== serverGib;
|
||||
const busy = update.isPending || remove.isPending;
|
||||
const error = [update.error, remove.error].find((e) => e instanceof ApiError) as
|
||||
| ApiError
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={user.handle}
|
||||
className="relative w-full max-w-md rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="flex items-center gap-1.5 font-semibold">
|
||||
<span className="truncate">{user.handle}</span>
|
||||
<Badges user={user} self={self} />
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-app-muted">
|
||||
{user.accounts.length ? user.accounts.join(", ") : t("admin.noMailAccounts")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title={t("admin.close")}
|
||||
aria-label={t("admin.close")}
|
||||
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-y border-app-border py-3">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-app-muted">{t("admin.userQuota")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="w-24 rounded border border-app-border bg-app-surface px-2 py-1 text-right focus:border-app-accent focus:outline-none"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.5"
|
||||
value={quotaValue}
|
||||
onChange={(e) => setQuotaDraft(e.target.value)}
|
||||
/>
|
||||
<span className="text-app-muted">GiB</span>
|
||||
<button
|
||||
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
disabled={!quotaDirty || !quotaValid || busy}
|
||||
onClick={() => update.mutate({ quota_bytes: Math.round(quotaGib * GIB) })}
|
||||
>
|
||||
{update.isPending && quotaDirty ? t("admin.saving") : t("admin.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-app-faint">
|
||||
{t("admin.usage", {
|
||||
used: formatBytes(user.used_bytes),
|
||||
quota: formatBytes(user.quota_bytes),
|
||||
})}
|
||||
</p>
|
||||
{!quotaValid && <p className="mt-1 text-xs text-red-500">{t("admin.quotaInvalid")}</p>}
|
||||
</div>
|
||||
|
||||
{user.blocked_at && (
|
||||
<p className="mt-3 text-sm text-red-500">
|
||||
{t("admin.blockedSince", { date: new Date(user.blocked_at).toLocaleString() })}
|
||||
</p>
|
||||
)}
|
||||
{self ? (
|
||||
<p className="mt-3 text-xs text-app-muted">{t("admin.selfNote")}</p>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
{user.blocked_at ? (
|
||||
<button
|
||||
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => update.mutate({ blocked: false })}
|
||||
>
|
||||
{t("admin.unblock")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm("block")}
|
||||
>
|
||||
{t("admin.block")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm("delete")}
|
||||
>
|
||||
{t("admin.deleteUser")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-2 text-xs text-red-500">{error.message}</p>}
|
||||
|
||||
{confirm && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 p-4"
|
||||
onClick={() => setConfirm(null)}
|
||||
>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
className="w-full max-w-sm rounded-lg border border-app-border bg-app-elevated p-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
{t(confirm === "block" ? "admin.blockConfirm" : "admin.deleteConfirm", {
|
||||
handle: user.handle,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="rounded-md px-3 py-1.5 text-sm text-app-muted hover:bg-app-hover"
|
||||
onClick={() => setConfirm(null)}
|
||||
autoFocus
|
||||
>
|
||||
{t("admin.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
confirm === "block" ? update.mutate({ blocked: true }) : remove.mutate()
|
||||
}
|
||||
>
|
||||
{t(confirm === "block" ? "admin.block" : "admin.deleteUser")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useId, useState } from "react";
|
||||
|
||||
// Keystrokes and IME composition; everything else that changes the field — a
|
||||
// browser or password-manager autofill (an input event without an inputType, or
|
||||
// "insertReplacementText"), a paste, a drop — is not the user typing.
|
||||
const TYPED = /^(insertText|insertCompositionText|insertLineBreak|delete)/;
|
||||
|
||||
/**
|
||||
* A password field that only accepts what is typed into it: for confirmations
|
||||
* where the point is that the person present knows the password, which an
|
||||
* autofilled saved password does not prove.
|
||||
*
|
||||
* `autoComplete="new-password"` and the password managers' opt-out attributes
|
||||
* keep most autofill away to begin with; whatever still gets in is thrown out
|
||||
* here, and the field says to type it.
|
||||
*/
|
||||
export function TypedPasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
hint,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
/** Shown when a fill or paste was refused. */
|
||||
hint: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [refused, setRefused] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className={className}
|
||||
type="password"
|
||||
// A fresh name per field, so nothing matches it to a saved login form.
|
||||
name={`typed-${id}`}
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
data-form-type="other"
|
||||
value={value}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
setRefused(true);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setRefused(true);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const inputType = (e.nativeEvent as InputEvent).inputType ?? "";
|
||||
if (!TYPED.test(inputType)) {
|
||||
onChange("");
|
||||
setRefused(true);
|
||||
return;
|
||||
}
|
||||
setRefused(false);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{refused && <span className="block text-xs text-amber-600">{hint}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+72
-2
@@ -3,7 +3,7 @@
|
||||
// Interpolate with {name} placeholders.
|
||||
|
||||
export const en = {
|
||||
"common.appName": "Webmail",
|
||||
"common.appName": "Ultimo Email",
|
||||
"common.loading": "Loading…",
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
@@ -34,6 +34,26 @@ export const en = {
|
||||
"auth.create": "Create account",
|
||||
"auth.creating": "Creating…",
|
||||
"auth.noAccount": "No account?",
|
||||
"auth.forgotPassword": "Forgot password?",
|
||||
"auth.forgotTitle": "Reset your password",
|
||||
"auth.forgotHint":
|
||||
"Enter your user name. We'll email a link for choosing a new password to the recovery email address of the account.",
|
||||
"auth.changePassword": "Change password",
|
||||
"auth.sending": "Sending…",
|
||||
"auth.forgotSent": "If this account exists you will receive an email to your recovery email address.",
|
||||
"auth.backToSignIn": "Back to sign in",
|
||||
"auth.resetTitle": "Choose a new password",
|
||||
"auth.newPassword": "New password",
|
||||
"auth.repeatPassword": "Repeat the new password",
|
||||
"auth.passwordsDiffer": "The two passwords are different.",
|
||||
"auth.passwordTooShort": "Use at least 8 characters.",
|
||||
"auth.resetTotpPrompt":
|
||||
"This account uses 2-step verification. Enter the 6-digit code from your authenticator app to finish.",
|
||||
"auth.confirm": "Confirm",
|
||||
"auth.resetDone": "Your password has been changed. You have been signed out everywhere — sign in with the new password.",
|
||||
"auth.resetInvalid":
|
||||
"This link is invalid, was already used, or has expired. Request a new one to change your password.",
|
||||
"auth.requestNewLink": "Request a new link",
|
||||
"auth.haveAccount": "Already have an account?",
|
||||
"auth.register": "Register",
|
||||
"auth.checking": "Checking…",
|
||||
@@ -327,6 +347,15 @@ export const en = {
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.handle": "Handle",
|
||||
"settings.recoveryEmail": "Recovery email",
|
||||
"settings.recoveryEmailHint":
|
||||
"If you forget your password, a link to choose a new one is sent here.",
|
||||
"settings.recoveryEmailNew": "Recovery email address",
|
||||
"settings.recoveryEmailRemoveHint": "Leave empty to remove it.",
|
||||
"settings.currentPassword": "Current password",
|
||||
"settings.typePassword": "Type your password — for this confirmation it can't be filled in or pasted.",
|
||||
"settings.change": "Change",
|
||||
"settings.add": "Add",
|
||||
"settings.saving": "Saving…",
|
||||
"settings.twoFactor": "Two-factor authentication",
|
||||
"settings.twoFactorOn": "2FA is enabled.",
|
||||
"settings.twoFactorOff": "2FA is not enabled.",
|
||||
@@ -412,7 +441,7 @@ export const en = {
|
||||
"settings.alsoFile": "Also mark the message as read and file it",
|
||||
"settings.integrations": "Integrations",
|
||||
|
||||
"admin.title": "Webmail administration",
|
||||
"admin.title": "Ultimo Email administration",
|
||||
"admin.signInTitle": "Administrator sign-in",
|
||||
"admin.signInHint": "Sign in with an administrator account to manage this server.",
|
||||
"admin.signOut": "Sign out",
|
||||
@@ -432,6 +461,47 @@ export const en = {
|
||||
"admin.saving": "Saving…",
|
||||
"admin.saved": "Saved",
|
||||
"admin.quotaInvalid": "Enter a size greater than 0.",
|
||||
"admin.mail": "Outgoing system mail",
|
||||
"admin.mailHelp":
|
||||
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
|
||||
"admin.mailOn": "Set up — password-reset links can be sent.",
|
||||
"admin.mailOff": "Not set up — password-reset links cannot be sent yet.",
|
||||
"admin.smtpHost": "SMTP server",
|
||||
"admin.smtpPort": "Port",
|
||||
"admin.smtpSecurity": "Security",
|
||||
"admin.smtpStarttls": "STARTTLS",
|
||||
"admin.smtpSsl": "SSL/TLS",
|
||||
"admin.smtpNone": "None",
|
||||
"admin.smtpUsername": "Login",
|
||||
"admin.smtpPassword": "Password",
|
||||
"admin.smtpPasswordKept": "(stored — leave empty to keep)",
|
||||
"admin.smtpPasswordRemove": "Remove the stored password",
|
||||
"admin.smtpFrom": "From address",
|
||||
"admin.publicUrl": "Public address of this server",
|
||||
"admin.publicUrlHint": "Where users open Ultimo Email, e.g. https://mail.example.com — reset links point here.",
|
||||
"admin.testTo": "Send a test email to",
|
||||
"admin.sendTest": "Send test",
|
||||
"admin.testSent": "Test email sent.",
|
||||
"admin.users": "Users",
|
||||
"admin.userSearch": "Search by login, email address or name…",
|
||||
"admin.noUsers": "No users match.",
|
||||
"admin.you": "you",
|
||||
"admin.adminBadge": "admin",
|
||||
"admin.blockedBadge": "blocked",
|
||||
"admin.usage": "{used} of {quota}",
|
||||
"admin.noMailAccounts": "No mail accounts connected",
|
||||
"admin.userQuota": "Quota",
|
||||
"admin.blockedSince": "Blocked since {date}. This user cannot sign in.",
|
||||
"admin.block": "Block account",
|
||||
"admin.unblock": "Unblock account",
|
||||
"admin.blockConfirm":
|
||||
"Block {handle}? They are signed out everywhere and cannot sign in until you unblock them. Their mail and data are kept.",
|
||||
"admin.deleteUser": "Delete account",
|
||||
"admin.deleteConfirm":
|
||||
"Permanently delete {handle} and all of their mail, contacts, calendars and settings? This can't be undone.",
|
||||
"admin.selfNote": "This is your own account: it cannot be blocked or deleted from here.",
|
||||
"admin.cancel": "Cancel",
|
||||
"admin.close": "Close",
|
||||
"settings.jitsi": "Jitsi Meet",
|
||||
"settings.jitsiHint":
|
||||
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
import type {
|
||||
AccountDetail,
|
||||
AdminMail,
|
||||
AdminSettings,
|
||||
AdminUser,
|
||||
AccountOut,
|
||||
AttachmentOut,
|
||||
CalendarOut,
|
||||
@@ -83,6 +85,38 @@ export const api = {
|
||||
// PUT merges: send only the fields you are changing.
|
||||
saveAdminSettings: (body: Partial<AdminSettings>) =>
|
||||
apiFetch<AdminSettings>("/admin/settings", { method: "PUT", body: JSON.stringify(body) }),
|
||||
adminUsers: (q: string) => apiFetch<AdminUser[]>(`/admin/users?q=${encodeURIComponent(q)}`),
|
||||
// PATCH merges: send only the fields you are changing.
|
||||
updateAdminUser: (id: string, body: { quota_bytes?: number; blocked?: boolean }) =>
|
||||
apiFetch<AdminUser>(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteAdminUser: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/admin/users/${id}`, { method: "DELETE" }),
|
||||
adminMail: () => apiFetch<AdminMail>("/admin/mail"),
|
||||
// PUT merges: omit smtp_password to keep the stored one, "" to remove it.
|
||||
saveAdminMail: (body: Partial<Omit<AdminMail, "has_password" | "configured">> & { smtp_password?: string }) =>
|
||||
apiFetch<AdminMail>("/admin/mail", { method: "PUT", body: JSON.stringify(body) }),
|
||||
testAdminMail: (to: string) =>
|
||||
apiFetch<{ status: string }>("/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }),
|
||||
passwordResetRequest: (handle: string) =>
|
||||
apiFetch<{ status: string }>("/auth/password/reset-request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ handle }),
|
||||
}),
|
||||
passwordResetCheck: (token: string) =>
|
||||
apiFetch<{ valid: boolean; totp_required: boolean }>("/auth/password/reset/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
passwordReset: (token: string, password: string, totp?: string) =>
|
||||
apiFetch<{ status: string }>("/auth/password/reset", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, password, totp }),
|
||||
}),
|
||||
setRecoveryEmail: (password: string, recovery_email: string | null) =>
|
||||
apiFetch<UserOut>("/auth/recovery-email", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ password, recovery_email }),
|
||||
}),
|
||||
registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"),
|
||||
register: (localpart: string, password: string) =>
|
||||
apiFetch<UserOut>("/auth/register", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type QueryClient, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, ApiError } from "./api";
|
||||
|
||||
@@ -13,3 +13,21 @@ export function useMe() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The server no longer knows this session — signed out elsewhere, or an
|
||||
* administrator blocked or deleted the account. Drop everything cached and let
|
||||
* the auth guard send the tab to the sign-in screen.
|
||||
*/
|
||||
export function signedOut(queryClient: QueryClient) {
|
||||
if (!queryClient.getQueryData(["me"])) return; // already signed out
|
||||
// "me" first, in place: its watchers see it and route to sign-in. Clearing the
|
||||
// whole cache first would orphan them from the query they watch.
|
||||
queryClient.setQueryData(["me"], null);
|
||||
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
|
||||
}
|
||||
|
||||
/** Whether an error means "this session is gone" (vs. a wrong password, etc.). */
|
||||
export function isSessionGone(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401 && error.code === "not_authenticated";
|
||||
}
|
||||
|
||||
@@ -15,6 +15,31 @@ export interface UserOut {
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
handle: string;
|
||||
is_admin: boolean;
|
||||
/** ISO timestamp while an administrator has blocked the account. */
|
||||
blocked_at: string | null;
|
||||
quota_bytes: number;
|
||||
used_bytes: number;
|
||||
created_at: string;
|
||||
/** The user's connected mail accounts. */
|
||||
accounts: string[];
|
||||
}
|
||||
|
||||
/** Outgoing system mail (password-reset links). The password is never sent back. */
|
||||
export interface AdminMail {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_security: "starttls" | "ssl" | "none";
|
||||
smtp_username: string;
|
||||
has_password: boolean;
|
||||
smtp_from: string;
|
||||
public_url: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
registration_open: boolean;
|
||||
/** Quota given to users created from now on; existing mailboxes keep theirs. */
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
import { signedOut } from "./auth";
|
||||
|
||||
const frameSchema = z.object({
|
||||
event: z.string(),
|
||||
payload: z.record(z.unknown()),
|
||||
@@ -20,6 +22,7 @@ const ALLOWED_EVENTS = new Set([
|
||||
"account.state_changed",
|
||||
"draft.saved",
|
||||
"outbox.state_changed",
|
||||
"user.signed_out",
|
||||
]);
|
||||
|
||||
// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3).
|
||||
@@ -82,6 +85,12 @@ export function connectWs(queryClient: QueryClient): () => void {
|
||||
}
|
||||
}
|
||||
if (!isCore) return;
|
||||
if (name === "user.signed_out") {
|
||||
// An administrator blocked or deleted this account: leave now, rather
|
||||
// than keep showing mail that was already loaded.
|
||||
signedOut(queryClient);
|
||||
return;
|
||||
}
|
||||
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
|
||||
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
|
||||
+10
-1
@@ -1,15 +1,24 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { isSessionGone, signedOut } from "./lib/auth";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./index.css";
|
||||
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
|
||||
|
||||
// Any request answered "not signed in" means the session is gone (signed out
|
||||
// elsewhere, or the account was blocked): go to sign-in instead of leaving the
|
||||
// page up with failing requests behind it.
|
||||
const onError = (error: unknown) => {
|
||||
if (isSessionGone(error)) signedOut(queryClient);
|
||||
};
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({ onError }),
|
||||
mutationCache: new MutationCache({ onError }),
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
|
||||
@@ -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