This commit is contained in:
1 parent
dcf62c882f
commit
1ce43495dd
10 files changed
+78
-17
No files matched your search
+1
-1
@@ -30,7 +30,7 @@ CORS_ORIGIN=http://localhost:5173
|
||||
# ─── Auth / sessions ─────────────────────────────────────────
|
||||
# Базовый URL ссылок подтверждения и сброса пароля. В production по умолчанию
|
||||
# используется https://run.vakanaut.ru; задайте URL явно для другого домена.
|
||||
APP_BASE_URL=http://localhost:5173
|
||||
# APP_BASE_URL=http://localhost:5173
|
||||
SESSION_SECRET=replace_with_32plus_char_random_secret
|
||||
# Production defaults to __Host-sid + Secure cookies. Local dev can stay insecure over http.
|
||||
# SESSION_COOKIE_NAME=__Host-sid
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
## Backend
|
||||
|
||||
### [1.7.2] — подготовлено локально, 2026-09-25
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Запросы сброса пароля и повторной отправки подтверждения сериализованы со сменой email.
|
||||
- Письма для локальной разработки снова содержат ссылку в dev-логах; в production токены не логируются.
|
||||
- Шаблон окружения оставляет `APP_BASE_URL` незаданным, чтобы production использовал домен по умолчанию.
|
||||
|
||||
### [1.7.1] — подготовлено локально, 2026-09-24
|
||||
|
||||
#### Fixed
|
||||
@@ -128,6 +136,12 @@
|
||||
|
||||
## Frontend
|
||||
|
||||
### [0.11.2] — подготовлено локально, 2026-09-25
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Таблица пользователей прокручивается внутри контейнера на узких экранах, не расширяя страницу.
|
||||
|
||||
### [0.11.1] — подготовлено локально, 2026-09-24
|
||||
|
||||
#### Fixed
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.2",
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -456,14 +456,21 @@ export async function verifyEmailToken(token: string): Promise<boolean> {
|
||||
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
const normalized = normalizeEmail(email);
|
||||
const user = await findUserByEmail(normalized);
|
||||
if (!user) {
|
||||
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
|
||||
return;
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const { rows } = await client.query<UserRow>(
|
||||
`SELECT id, email, password_hash, email_verified_at, profile_username,
|
||||
is_future_races_public, is_completed_races_public
|
||||
FROM users WHERE LOWER(BTRIM(email)) = $1 FOR UPDATE`,
|
||||
[normalized],
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) {
|
||||
await client.query("COMMIT");
|
||||
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
|
||||
return;
|
||||
}
|
||||
const token = await createResetToken(client, user.id);
|
||||
await client.query("COMMIT");
|
||||
await sendResetEmail(user.email, token);
|
||||
@@ -521,13 +528,20 @@ export async function resetPassword(token: string, password: string): Promise<bo
|
||||
|
||||
export async function resendVerification(email: string): Promise<void> {
|
||||
const normalized = normalizeEmail(email);
|
||||
const user = await findUserByEmail(normalized);
|
||||
if (!user || user.email_verified_at) {
|
||||
return;
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const { rows } = await client.query<UserRow>(
|
||||
`SELECT id, email, password_hash, email_verified_at, profile_username,
|
||||
is_future_races_public, is_completed_races_public
|
||||
FROM users WHERE LOWER(BTRIM(email)) = $1 FOR UPDATE`,
|
||||
[normalized],
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || user.email_verified_at) {
|
||||
await client.query("COMMIT");
|
||||
return;
|
||||
}
|
||||
const token = await createVerificationToken(client, user.id);
|
||||
await client.query("COMMIT");
|
||||
await sendVerificationEmail(user.email, token);
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function sendMail(to: string, subject: string, text: string): Promi
|
||||
if (config.securityProfile === "production") {
|
||||
throw new Error("SMTP is required in production to send email");
|
||||
}
|
||||
console.info("[mail] SMTP is not configured; email skipped.", { to, subject });
|
||||
console.info("[mail] SMTP is not configured; development email:", { to, subject, text });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
cleanupExpiredAuthRows,
|
||||
createResetToken,
|
||||
createVerificationToken,
|
||||
requestPasswordReset,
|
||||
resendVerification,
|
||||
resetPassword,
|
||||
verifyEmailToken,
|
||||
} from "../src/authService";
|
||||
@@ -552,6 +554,34 @@ test("email verification token is single use", async () => {
|
||||
assert.equal(await verifyEmailToken(token), false);
|
||||
});
|
||||
|
||||
test("email changes serialize password reset and verification token issuance", async () => {
|
||||
for (const [email, issueToken] of [
|
||||
["reset-race@example.com", requestPasswordReset],
|
||||
["verify-race@example.com", resendVerification],
|
||||
] as const) {
|
||||
const userId = await createUnverifiedUser(email, "correct horse battery staple");
|
||||
const locker = await pool.connect();
|
||||
await locker.query("BEGIN");
|
||||
await locker.query("SELECT id FROM users WHERE id = $1 FOR UPDATE", [userId]);
|
||||
const issuing = issueToken(email);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await locker.query("UPDATE users SET email = $2, email_verified_at = NULL WHERE id = $1", [
|
||||
userId,
|
||||
email.replace("@", ".changed@"),
|
||||
]);
|
||||
await locker.query("COMMIT");
|
||||
locker.release();
|
||||
await issuing;
|
||||
|
||||
const table = issueToken === requestPasswordReset ? "password_reset_tokens" : "email_verification_tokens";
|
||||
const { rows } = await pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count FROM ${table} WHERE user_id = $1 AND used_at IS NULL`,
|
||||
[userId],
|
||||
);
|
||||
assert.equal(rows[0]?.count, "0");
|
||||
}
|
||||
});
|
||||
|
||||
test("auth cleanup removes expired rows and keeps active rows", async () => {
|
||||
const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple");
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"ag-charts-community": "^14.0.0",
|
||||
"ag-charts-react": "^14.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -27,10 +27,12 @@ a {
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.app-shell__header {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -85,6 +87,7 @@ a {
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
min-width: 0;
|
||||
width: min(1080px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6);
|
||||
|
||||
Reference in new issue
Block a user