feat(auth): add password reset flow

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-01 21:46:19 +02:00
1 parent 21f91d6275
commit 3a05fae555
18 files changed
+585 -10

No files matched your search

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "passwordResetExpires" TIMESTAMP(3),
ADD COLUMN "passwordResetTokenHash" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_passwordResetTokenHash_key" ON "User"("passwordResetTokenHash");
+3
View File
@@ -24,6 +24,9 @@ model User {
emailVerificationExpires DateTime?
// Awaiting confirmation of an email change.
pendingEmail String?
// SHA-256 of the emailed token, so a database leak can't be used to take over accounts.
passwordResetTokenHash String? @unique
passwordResetExpires DateTime?
// Per-user, unlike the instance-wide Thingiverse token.
makerworldCookie String?
slicer String?
+83 -1
View File
@@ -8,7 +8,7 @@ import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { getAllowRegistrations, isSmtpConfigured, type CaptchaPlace } from "../services/settingsService";
import { sendVerificationEmail } from "../services/mailer";
import { sendPasswordResetEmail, sendVerificationEmail } from "../services/mailer";
import { findValidInvitation } from "../services/invitationService";
import { checkCaptcha } from "../services/captchaService";
import { createLog } from "../services/auditLog";
@@ -20,6 +20,9 @@ const router = Router();
const PASSWORD_HASH_COST = 12;
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
// Stops the forgot-password form from being used to flood someone's inbox.
const PASSWORD_RESET_RESEND_MS = 60 * 1000;
function newVerificationToken(): { token: string; expires: Date } {
return { token: crypto.randomBytes(32).toString("hex"), expires: new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS) };
@@ -205,6 +208,85 @@ router.post(
}),
);
const hashResetToken = (token: string) => crypto.createHash("sha256").update(token).digest("hex");
async function findUserByResetToken(token: string) {
const user = await prisma.user.findUnique({ where: { passwordResetTokenHash: hashResetToken(token) } });
if (!user || !user.passwordResetExpires || user.passwordResetExpires < new Date()) {
throw new HttpError(400, "This password reset link is invalid or has expired. Ask for a new one.");
}
return user;
}
const forgotPasswordSchema = z.object({ email: z.string().trim().email("Enter a valid email address") });
router.post(
"/forgot-password",
asyncHandler(async (req, res) => {
const body = parseBody(forgotPasswordSchema, req.body);
const email = body.email.toLowerCase();
// Same response in every case, to prevent enumeration.
const user = await prisma.user.findUnique({ where: { email } });
const recentlySent =
user?.passwordResetExpires &&
user.passwordResetExpires.getTime() - PASSWORD_RESET_TTL_MS > Date.now() - PASSWORD_RESET_RESEND_MS;
if (user && !recentlySent && (await isSmtpConfigured())) {
const token = crypto.randomBytes(32).toString("hex");
await prisma.user.update({
where: { id: user.id },
data: {
passwordResetTokenHash: hashResetToken(token),
passwordResetExpires: new Date(Date.now() + PASSWORD_RESET_TTL_MS),
},
});
try {
await sendPasswordResetEmail(user.email, user.displayName, token);
void createLog({ userId: user.id, action: "password_reset_requested", details: { email: user.email } });
} catch (err) {
console.error("[auth] Failed to send password reset email:", err);
}
}
res.json({ message: "If an account uses that email, we've sent it a link to reset the password." });
}),
);
// Lets the reset form say which account it's for, and fail before the user types a password.
router.get(
"/reset-password/:token",
asyncHandler(async (req, res) => {
const user = await findUserByResetToken(req.params.token);
res.json({ email: user.email });
}),
);
const resetPasswordSchema = z.object({
token: z.string().min(1),
new_password: z.string().min(8, "Password must be at least 8 characters"),
});
router.post(
"/reset-password",
asyncHandler(async (req, res) => {
const body = parseBody(resetPasswordSchema, req.body);
const user = await findUserByResetToken(body.token);
const updated = await prisma.user.update({
where: { id: user.id },
data: {
passwordHash: await bcrypt.hash(body.new_password, PASSWORD_HASH_COST),
passwordResetTokenHash: null,
passwordResetExpires: null,
// The link reached this mailbox, which is all verification proves.
emailVerified: true,
},
});
// Resetting doubles as signing in.
const { token, expiresIn } = await issueToken(updated.id, updated.role);
res.json({ token, expires_in: expiresIn, user: toUserOut(updated) });
void createLog({ userId: updated.id, action: "password_reset", details: { email: updated.email } });
void createLog({ userId: updated.id, action: "user_logged_in", details: { email: updated.email } });
}),
);
const updateProfileSchema = z
.object({
current_password: z.string().min(1, "Current password is required"),
+3 -1
View File
@@ -1,7 +1,7 @@
import { Router } from "express";
import { asyncHandler } from "../utils/asyncHandler";
import { prisma } from "../db";
import { getAllowRegistrations } from "../services/settingsService";
import { getAllowRegistrations, isSmtpConfigured } from "../services/settingsService";
const router = Router();
@@ -14,6 +14,8 @@ router.get(
ok: true,
auth_required: true,
allow_registrations: bootstrapping || (await getAllowRegistrations(true)),
// The reset link is emailed, so the sign-in form only offers it with SMTP set up.
password_reset_enabled: await isSmtpConfigured(),
});
}),
);
+2
View File
@@ -4,6 +4,8 @@ import { prisma } from "../db";
export type LogAction =
| "user_logged_in"
| "user_logged_out"
| "password_reset_requested"
| "password_reset"
| "user_invited"
| "authors_linked"
| "model_uploaded"
+10
View File
@@ -33,6 +33,16 @@ export async function sendVerificationEmail(to: string, displayName: string, tok
});
}
export async function sendPasswordResetEmail(to: string, displayName: string, token: string): Promise<void> {
const link = `${PUBLIC_URL}/reset-password?token=${encodeURIComponent(token)}`;
await sendMail({
to,
subject: "Reset your Thingport password",
text: `Hi ${displayName},\n\nSomeone asked to reset the password for your Thingport account. To choose a new password, open:\n${link}\n\nThis link expires in 1 hour. If you didn't ask for this, you can ignore this email.`,
html: `<p>Hi ${escapeHtml(displayName)},</p><p>Someone asked to reset the password for your Thingport account. To choose a new password, open:</p><p><a href="${link}">${link}</a></p><p>This link expires in 1 hour. If you didn't ask for this, you can ignore this email.</p>`,
});
}
/** `link` is the full registration URL, token included. */
export async function sendInvitationEmail(
to: string,
+140
View File
@@ -0,0 +1,140 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import request from "supertest";
// The mailer is mocked; tests read the reset token from its calls.
vi.mock("../src/services/mailer", () => ({
sendVerificationEmail: vi.fn<(to: string, displayName: string, token: string) => Promise<void>>(
async () => undefined,
),
sendInvitationEmail: vi.fn<(to: string, inviterName: string, link: string, expiresInDays: number) => Promise<void>>(
async () => undefined,
),
sendPasswordResetEmail: vi.fn<(to: string, displayName: string, token: string) => Promise<void>>(
async () => undefined,
),
}));
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { sendPasswordResetEmail } from "../src/services/mailer";
import { getSmtpSettings, setSmtpSettings } from "../src/services/settingsService";
const app = createApp();
const sendReset = vi.mocked(sendPasswordResetEmail);
const stamp = Date.now();
const email = (name: string) => `${name}-${stamp}@example.com`;
// The database is shared across files, so restore the SMTP host afterwards.
let previousSmtpHost: string | null;
const forgot = (address: string) => request(app).post("/api/forgot-password").send({ email: address });
const reset = (token: string, password: string) =>
request(app).post("/api/reset-password").send({ token, new_password: password });
const login = (address: string, password: string) => request(app).post("/api/login").send({ email: address, password });
async function registerUser(name: string): Promise<void> {
await request(app)
.post("/api/register")
.send({ displayName: name, email: email(name), password: "password123" });
}
/** Asks for a link and returns the token from the (mocked) email. */
async function requestToken(name: string): Promise<string> {
sendReset.mockClear();
expect((await forgot(email(name))).status).toBe(200);
expect(sendReset).toHaveBeenCalledTimes(1);
return sendReset.mock.calls[0][2];
}
beforeAll(async () => {
previousSmtpHost = (await getSmtpSettings()).host;
// Registered without SMTP so they skip email verification.
await setSmtpSettings({ host: null });
for (const name of ["reset-a", "reset-b", "reset-c", "reset-d", "reset-e", "reset-f", "reset-off"])
await registerUser(name);
});
afterAll(async () => {
await setSmtpSettings({ host: previousSmtpHost });
});
beforeEach(async () => {
sendReset.mockClear();
await setSmtpSettings({ host: "smtp.example.test" });
});
describe("password reset", () => {
it("is only offered on the sign-in form with SMTP configured", async () => {
expect((await request(app).get("/api/health")).body.password_reset_enabled).toBe(true);
await setSmtpSettings({ host: null });
expect((await request(app).get("/api/health")).body.password_reset_enabled).toBe(false);
});
it("doesn't send anything without SMTP", async () => {
await setSmtpSettings({ host: null });
expect((await forgot(email("reset-off"))).status).toBe(200);
expect(sendReset).not.toHaveBeenCalled();
});
it("answers the same for unknown emails, without sending", async () => {
const known = await forgot(email("reset-a"));
const unknown = await forgot(email("nobody"));
expect(unknown.status).toBe(200);
expect(unknown.body).toEqual(known.body);
expect(sendReset).toHaveBeenCalledTimes(1);
});
it("sets the new password and signs in", async () => {
const token = await requestToken("reset-b");
expect((await request(app).get(`/api/reset-password/${token}`)).body.email).toBe(email("reset-b"));
const res = await reset(token, "new-password-1");
expect(res.status).toBe(200);
expect(res.body.token).toEqual(expect.any(String));
expect(res.body.user.email).toBe(email("reset-b"));
expect((await login(email("reset-b"), "password123")).status).toBe(401);
expect((await login(email("reset-b"), "new-password-1")).status).toBe(200);
});
it("records the request and the reset in the admin logs", async () => {
const userId = (await prisma.user.findUniqueOrThrow({ where: { email: email("reset-b") } })).id;
const actions = async () =>
(await prisma.log.findMany({ where: { userId }, select: { action: true } })).map((l) => l.action);
// Logging is fire-and-forget, so it can land just after the response.
await expect.poll(actions).toEqual(expect.arrayContaining(["password_reset_requested", "password_reset"]));
});
it("only stores a hash of the token", async () => {
const token = await requestToken("reset-c");
const user = await prisma.user.findUnique({ where: { email: email("reset-c") } });
expect(user?.passwordResetTokenHash).toBeTruthy();
expect(user?.passwordResetTokenHash).not.toBe(token);
});
it("works only once", async () => {
const token = await requestToken("reset-d");
expect((await reset(token, "new-password-1")).status).toBe(200);
expect((await reset(token, "new-password-2")).status).toBe(400);
expect((await request(app).get(`/api/reset-password/${token}`)).status).toBe(400);
});
it("expires", async () => {
const token = await requestToken("reset-e");
await prisma.user.update({
where: { email: email("reset-e") },
data: { passwordResetExpires: new Date(Date.now() - 1000) },
});
expect((await reset(token, "new-password-1")).status).toBe(400);
expect((await login(email("reset-e"), "password123")).status).toBe(200);
});
it("won't email the same account again within a minute", async () => {
await requestToken("reset-f");
await forgot(email("reset-f"));
expect(sendReset).toHaveBeenCalledTimes(1);
});
it("rejects short passwords and unknown tokens", async () => {
expect((await reset("not-a-token", "new-password-1")).status).toBe(400);
expect((await reset("not-a-token", "short")).status).toBe(400);
});
});
+5
View File
@@ -17,6 +17,7 @@ import TagDetailPage from "./pages/TagDetailPage";
import AuthorPage from "./pages/AuthorPage";
import AuthPage from "./pages/AuthPage";
import VerifyEmailPage from "./pages/VerifyEmailPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import ProfilePage from "./pages/ProfilePage";
import ChangeEmailPage from "./pages/ProfilePage/ChangeEmailPage";
import ChangePasswordPage from "./pages/ProfilePage/ChangePasswordPage";
@@ -371,6 +372,7 @@ export default function App() {
if (authRequired && !token) {
// Outside the router, so these pre-login links are matched against window.location.
const isVerifyEmailPath = typeof window !== "undefined" && window.location.pathname === "/verify-email";
const isResetPasswordPath = typeof window !== "undefined" && window.location.pathname === "/reset-password";
const inviteParams =
typeof window !== "undefined" && window.location.pathname === "/register"
? new URLSearchParams(window.location.search)
@@ -393,11 +395,14 @@ export default function App() {
>
{isVerifyEmailPath ? (
<VerifyEmailPage onSuccess={handleLogin} />
) : isResetPasswordPath ? (
<ResetPasswordPage onSuccess={handleLogin} />
) : (
<AuthPage
onSuccess={handleLogin}
apiUp={apiUp}
allowRegistrations={health?.allow_registrations ?? true}
passwordResetEnabled={health?.password_reset_enabled ?? false}
invite={invite}
/>
)}
+2
View File
@@ -15,6 +15,8 @@ export type AdminUser = {
export type LogAction =
| "user_logged_in"
| "user_logged_out"
| "password_reset_requested"
| "password_reset"
| "user_invited"
| "authors_linked"
| "model_uploaded"
+14
View File
@@ -72,6 +72,20 @@ export const authApi = {
resendVerification: (email: string): Promise<{ message: string }> => postAuth("/resend-verification", { email }),
/** Replies the same whether or not the email has an account. */
forgotPassword: (email: string): Promise<{ message: string }> => postAuth("/forgot-password", { email }),
/** The account a reset link is for; throws if the link is invalid or expired. */
getPasswordReset: async (token: string): Promise<{ email: string }> => {
const res = await fetch(`${apiBase()}/reset-password/${encodeURIComponent(token)}`);
if (!res.ok) return readAuthError(res);
return res.json();
},
/** Also signs in. */
resetPassword: (token: string, newPassword: string): Promise<AuthResult> =>
postAuth("/reset-password", { token, new_password: newPassword }),
updateProfile: async (payload: UpdateProfileInput): Promise<{ user: AuthUser }> => {
const res = await fetch(`${apiBase()}/profile`, {
method: "PATCH",
+7 -1
View File
@@ -1,6 +1,11 @@
import { apiBase } from "./client";
export type HealthInfo = { ok: boolean; auth_required: boolean; allow_registrations: boolean };
export type HealthInfo = {
ok: boolean;
auth_required: boolean;
allow_registrations: boolean;
password_reset_enabled: boolean;
};
export const healthApi = {
get: async (): Promise<HealthInfo | null> => {
@@ -12,6 +17,7 @@ export const healthApi = {
ok: Boolean(data?.ok),
auth_required: Boolean(data?.auth_required),
allow_registrations: data?.allow_registrations !== false,
password_reset_enabled: data?.password_reset_enabled === true,
};
} catch {
return null;
+27 -1
View File
@@ -16,7 +16,8 @@
"passwordLabel": "Password",
"submit": "Sign in",
"submitting": "Signing in...",
"failed": "Login failed"
"failed": "Login failed",
"forgotPassword": "Forgot password?"
},
"register": {
"displayNameLabel": "Display Name",
@@ -46,6 +47,29 @@
"missingToken": "This verification link is missing its token.",
"failed": "This verification link is invalid or has expired.",
"backToSignIn": "Back to sign in"
},
"forgotPassword": {
"heading": "Reset your password",
"body": "Enter your account's email and we'll send you a link to choose a new password.",
"emailLabel": "Email",
"submit": "Send reset link",
"submitting": "Sending…",
"sentHeading": "Check your email",
"sentBody": "If an account uses {{email}}, we've sent it a link to reset the password. The link expires in 1 hour.",
"failed": "Couldn't send the email. Please try again.",
"backToSignIn": "Back to sign in"
},
"resetPassword": {
"heading": "Choose a new password",
"forAccount": "For {{email}}. You will be signed in once it is saved.",
"checking": "Checking your link…",
"newPasswordLabel": "New password",
"confirmPasswordLabel": "Confirm new password",
"submit": "Save and sign in",
"submitting": "Saving…",
"missingToken": "This password reset link is missing its token.",
"failed": "This password reset link is invalid or has expired. Ask for a new one.",
"backToSignIn": "Back to sign in"
}
},
"sidebar": {
@@ -530,6 +554,8 @@
"action": {
"user_logged_in": "Logged in",
"user_logged_out": "Logged out",
"password_reset_requested": "Requested a password reset",
"password_reset": "Reset password",
"user_invited": "Invited a user",
"model_uploaded": "Model uploaded",
"model_imported": "Model imported",
+27 -1
View File
@@ -16,7 +16,8 @@
"passwordLabel": "Slaptažodis",
"submit": "Prisijungti",
"submitting": "Jungiamasi...",
"failed": "Prisijungti nepavyko"
"failed": "Prisijungti nepavyko",
"forgotPassword": "Pamiršote slaptažodį?"
},
"register": {
"displayNameLabel": "Rodomas vardas",
@@ -46,6 +47,29 @@
"missingToken": "Šioje patvirtinimo nuorodoje trūksta rakto.",
"failed": "Ši patvirtinimo nuoroda negalioja arba jos galiojimas baigėsi.",
"backToSignIn": "Grįžti į prisijungimą"
},
"forgotPassword": {
"heading": "Slaptažodžio atkūrimas",
"body": "Įveskite paskyros el. pašto adresą ir atsiųsime nuorodą naujam slaptažodžiui pasirinkti.",
"emailLabel": "El. paštas",
"submit": "Siųsti nuorodą",
"submitting": "Siunčiama…",
"sentHeading": "Patikrinkite savo el. paštą",
"sentBody": "Jei adresu {{email}} yra paskyra, išsiuntėme į jį slaptažodžio atkūrimo nuorodą. Nuoroda galioja 1 valandą.",
"failed": "Nepavyko išsiųsti laiško. Bandykite dar kartą.",
"backToSignIn": "Grįžti į prisijungimą"
},
"resetPassword": {
"heading": "Pasirinkite naują slaptažodį",
"forAccount": "Paskyrai {{email}}. Išsaugoję būsite prijungti.",
"checking": "Tikrinama nuoroda…",
"newPasswordLabel": "Naujas slaptažodis",
"confirmPasswordLabel": "Pakartokite naują slaptažodį",
"submit": "Išsaugoti ir prisijungti",
"submitting": "Išsaugoma…",
"missingToken": "Šioje slaptažodžio atkūrimo nuorodoje trūksta rakto.",
"failed": "Ši slaptažodžio atkūrimo nuoroda negalioja arba jos galiojimas baigėsi. Paprašykite naujos.",
"backToSignIn": "Grįžti į prisijungimą"
}
},
"sidebar": {
@@ -532,6 +556,8 @@
"action": {
"user_logged_in": "Prisijungė",
"user_logged_out": "Atsijungė",
"password_reset_requested": "Paprašė atkurti slaptažodį",
"password_reset": "Atkūrė slaptažodį",
"user_invited": "Pakvietė naudotoją",
"model_uploaded": "Modelis įkeltas",
"model_imported": "Modelis importuotas",
@@ -0,0 +1,81 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead";
import { authApi } from "../../api/auth";
type Props = {
initialEmail: string;
onBack: () => void;
};
/** The backend replies the same whether or not the email has an account, so "sent" is all we can say. */
export default function ForgotPasswordPanel({ initialEmail, onBack }: Props) {
const { t } = useTranslation("app");
const [email, setEmail] = React.useState(initialEmail);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [sentTo, setSentTo] = React.useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await authApi.forgotPassword(email.trim());
setSentTo(email.trim());
} catch (err) {
setError(err instanceof Error ? err.message : t("auth.forgotPassword.failed"));
} finally {
setLoading(false);
}
};
if (sentTo) {
return (
<Stack spacing={2} alignItems="center" sx={{ textAlign: "center", py: 1 }}>
<MarkEmailReadIcon sx={{ fontSize: 40, color: "primary.main" }} />
<Typography variant="h6">{t("auth.forgotPassword.sentHeading")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("auth.forgotPassword.sentBody", { email: sentTo })}
</Typography>
<Button variant="outlined" onClick={onBack}>
{t("auth.forgotPassword.backToSignIn")}
</Button>
</Stack>
);
}
return (
<Box component="form" onSubmit={handleSubmit}>
<Stack spacing={2}>
<Typography variant="h6">{t("auth.forgotPassword.heading")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("auth.forgotPassword.body")}
</Typography>
{error && <Alert severity="error">{error}</Alert>}
<TextField
type="email"
label={t("auth.forgotPassword.emailLabel")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
required
fullWidth
size="small"
/>
<Button type="submit" variant="contained" disabled={loading} fullWidth size="large">
{loading ? t("auth.forgotPassword.submitting") : t("auth.forgotPassword.submit")}
</Button>
<Button onClick={onBack} disabled={loading}>
{t("auth.forgotPassword.backToSignIn")}
</Button>
</Stack>
</Box>
);
}
+11 -1
View File
@@ -5,6 +5,7 @@ import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import { authApi, type AuthUser } from "../../api/auth";
import { EmailNotVerifiedError } from "../../api/client";
import type { CaptchaAnswer } from "../../api/captcha";
@@ -14,9 +15,11 @@ import ResendVerificationButton from "./ResendVerificationButton";
type Props = {
onSuccess: (token: string, expires_in: number, user: AuthUser) => void;
/** Shown only when set, i.e. when SMTP is configured. Gets whatever email was typed. */
onForgotPassword?: (email: string) => void;
};
export default function SignInPanel({ onSuccess }: Props) {
export default function SignInPanel({ onSuccess, onForgotPassword }: Props) {
const { t } = useTranslation("app");
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
@@ -75,6 +78,13 @@ export default function SignInPanel({ onSuccess }: Props) {
fullWidth
size="small"
/>
{onForgotPassword && (
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: "4px !important" }}>
<Link component="button" type="button" variant="body2" onClick={() => onForgotPassword(email.trim())}>
{t("auth.signIn.forgotPassword")}
</Link>
</Box>
)}
{captchaSettings?.login && <CaptchaField key={captchaKey} onChange={setCaptcha} disabled={loading} />}
<Button type="submit" variant="contained" disabled={loading} fullWidth size="large">
{loading ? t("auth.signIn.submitting") : t("auth.signIn.submit")}
+10 -4
View File
@@ -11,17 +11,21 @@ import type { AuthUser } from "../../api/auth";
import Wordmark from "../../components/Wordmark";
import SignInPanel from "./SignInPanel";
import RegisterPanel, { type Invite } from "./RegisterPanel";
import ForgotPasswordPanel from "./ForgotPasswordPanel";
type Props = {
onSuccess: (token: string, expires_in: number, user: AuthUser) => void;
apiUp: boolean | null;
allowRegistrations: boolean;
passwordResetEnabled: boolean;
invite?: Invite | null;
};
export default function AuthPage({ onSuccess, apiUp, allowRegistrations, invite = null }: Props) {
export default function AuthPage({ onSuccess, apiUp, allowRegistrations, passwordResetEnabled, invite = null }: Props) {
const { t } = useTranslation("app");
const [tab, setTab] = React.useState<"signIn" | "register">(invite ? "register" : "signIn");
// Set while the forgot-password form replaces the tabs; carries over the typed sign-in email.
const [forgotEmail, setForgotEmail] = React.useState<string | null>(null);
// Closed registrations hide the Register tab unless this visit came from an invitation.
const canRegister = allowRegistrations || invite !== null;
@@ -52,7 +56,7 @@ export default function AuthPage({ onSuccess, apiUp, allowRegistrations, invite
</Alert>
)}
{canRegister && (
{forgotEmail === null && canRegister && (
<Tabs
value={tab}
onChange={(_e, value) => setTab(value)}
@@ -64,10 +68,12 @@ export default function AuthPage({ onSuccess, apiUp, allowRegistrations, invite
</Tabs>
)}
{tab === "register" && canRegister ? (
{forgotEmail !== null ? (
<ForgotPasswordPanel initialEmail={forgotEmail} onBack={() => setForgotEmail(null)} />
) : tab === "register" && canRegister ? (
<RegisterPanel onSuccess={onSuccess} invite={invite} />
) : (
<SignInPanel onSuccess={onSuccess} />
<SignInPanel onSuccess={onSuccess} onForgotPassword={passwordResetEnabled ? setForgotEmail : undefined} />
)}
</Paper>
);
+2
View File
@@ -30,6 +30,8 @@ type ActionColor = "success" | "error" | "info" | "warning" | "default";
const ACTION_COLORS: Record<LogAction, ActionColor> = {
user_logged_in: "success",
user_logged_out: "default",
password_reset_requested: "default",
password_reset: "warning",
user_invited: "info",
authors_linked: "success",
model_uploaded: "info",
@@ -0,0 +1,152 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Wordmark from "../../components/Wordmark";
import { authApi, type AuthUser } from "../../api/auth";
const MIN_PASSWORD_LENGTH = 8;
type Props = {
onSuccess: (token: string, expires_in: number, user: AuthUser) => void;
};
const backToSignIn = () => {
window.history.replaceState(null, "", "/");
window.location.reload();
};
/** Rendered outside the router, so `token` is read from window.location. Saving signs in. */
export default function ResetPasswordPage({ onSuccess }: Props) {
const { t } = useTranslation("app");
const [token] = React.useState(() => new URLSearchParams(window.location.search).get("token"));
const [accountEmail, setAccountEmail] = React.useState<string | null>(null);
// A dead link can't be retried, unlike a rejected password.
const [linkError, setLinkError] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [newPassword, setNewPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!token) {
setLinkError(t("auth.resetPassword.missingToken"));
return;
}
authApi.getPasswordReset(token).then(
(res) => setAccountEmail(res.email),
(err) => setLinkError(err instanceof Error ? err.message : t("auth.resetPassword.failed")),
);
}, [token, t]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setError(null);
if (newPassword.length < MIN_PASSWORD_LENGTH) {
setError(t("auth.register.passwordTooShort"));
return;
}
if (newPassword !== confirmPassword) {
setError(t("auth.register.passwordMismatch"));
return;
}
setLoading(true);
try {
const res = await authApi.resetPassword(token, newPassword);
window.history.replaceState(null, "", "/");
onSuccess(res.token, res.expires_in, res.user);
} catch (err) {
setError(err instanceof Error ? err.message : t("auth.resetPassword.failed"));
setLoading(false);
}
};
let content: React.ReactNode;
if (linkError) {
content = (
<Stack spacing={2} alignItems="center">
<Alert severity="error" sx={{ width: "100%" }}>
{linkError}
</Alert>
<Button variant="contained" onClick={backToSignIn}>
{t("auth.resetPassword.backToSignIn")}
</Button>
</Stack>
);
} else if (!accountEmail) {
content = (
<Stack spacing={2} alignItems="center">
<CircularProgress size={28} />
<Typography variant="body2" color="text.secondary">
{t("auth.resetPassword.checking")}
</Typography>
</Stack>
);
} else {
content = (
<Box component="form" onSubmit={handleSubmit} sx={{ textAlign: "left" }}>
<Stack spacing={2}>
<Typography variant="h6">{t("auth.resetPassword.heading")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("auth.resetPassword.forAccount", { email: accountEmail })}
</Typography>
{error && <Alert severity="error">{error}</Alert>}
{/* Lets password managers file the new password under the right account. */}
<input type="email" autoComplete="username" value={accountEmail} readOnly hidden />
<TextField
type="password"
label={t("auth.resetPassword.newPasswordLabel")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
helperText={t("auth.register.passwordHelp")}
required
fullWidth
size="small"
/>
<TextField
type="password"
label={t("auth.resetPassword.confirmPasswordLabel")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
fullWidth
size="small"
/>
<Button type="submit" variant="contained" disabled={loading} fullWidth size="large">
{loading ? t("auth.resetPassword.submitting") : t("auth.resetPassword.submit")}
</Button>
</Stack>
</Box>
);
}
return (
<Paper
elevation={8}
sx={{
width: "100%",
maxWidth: 420,
borderRadius: 3,
p: 4,
border: "1px solid",
borderColor: "divider",
}}
>
<Stack spacing={3} sx={{ textAlign: "center" }}>
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Wordmark size="lg" />
</Box>
{content}
</Stack>
</Paper>
);
}