mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat(auth): add password reset flow
This commit is contained in:
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");
|
||||
@@ -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?
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user