From c3e4f5bdd2bcd2bbc9ad356f53bf4762d5c75ed9 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 11:26:00 +0500 Subject: [PATCH 01/23] feat(server): add POST /api/auth/register and /api/auth/login - Add register endpoint with email/password validation, bcrypt hashing - Add login endpoint with rate limiting per IP (5 attempts/min) - Add helper functions: validatePassword, hashPassword, comparePassword, isAdminEmail - Add checkLoginRateLimit for brute-force protection - Add bcrypt dependency - Remove avatarType column from User (migration) --- server/package.json | 1 + .../migration.sql | 28 +++++++ server/prisma/schema.prisma | 1 - server/src/lib/auth.js | 32 ++++++++ server/src/lib/rate-limit.js | 19 +++++ server/src/routes/auth.js | 77 ++++++++++++++++++- 6 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql create mode 100644 server/src/lib/rate-limit.js diff --git a/server/package.json b/server/package.json index 37080f8..ed46702 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", + "bcrypt": "^6.0.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", "nodemailer": "^8.0.7", diff --git a/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql b/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql new file mode 100644 index 0000000..b9ee70a --- /dev/null +++ b/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the column `avatarType` on the `User` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "displayName" TEXT, + "firstName" TEXT, + "lastName" TEXT, + "gender" TEXT, + "avatar" TEXT, + "avatarStyle" TEXT, + "passwordHash" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("avatar", "avatarStyle", "createdAt", "displayName", "email", "firstName", "gender", "id", "lastName", "passwordHash", "updatedAt") SELECT "avatar", "avatarStyle", "createdAt", "displayName", "email", "firstName", "gender", "id", "lastName", "passwordHash", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3bc4946..be0aa90 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -82,7 +82,6 @@ model User { lastName String? gender String? avatar String? - avatarType String? avatarStyle String? passwordHash String? createdAt DateTime @default(now()) diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index fcd6996..cdfc5bb 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -1,4 +1,5 @@ import crypto from 'node:crypto' +import bcrypt from 'bcrypt' import { sendLoginCodeEmail } from './email.js' import { prisma } from './prisma.js' @@ -72,3 +73,34 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) { }) return true } + +const PASSWORD_MIN_LEN = 8 + +const PASSWORD_REGEX = { + letter: /[a-zа-яё]/i, + digit: /[0-9]/, + special: /[^a-zа-яё0-9\s]/i, +} + +export function validatePassword(password) { + if (typeof password !== 'string') return 'Пароль обязателен' + if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов` + if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву' + if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру' + if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол' + return null +} + +export async function hashPassword(password) { + return bcrypt.hash(password, 10) +} + +export async function comparePassword(password, hash) { + return bcrypt.compare(password, hash) +} + +export function isAdminEmail(email) { + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() + if (!adminEmail) return false + return normalizeEmail(email) === adminEmail +} diff --git a/server/src/lib/rate-limit.js b/server/src/lib/rate-limit.js new file mode 100644 index 0000000..e37a9f0 --- /dev/null +++ b/server/src/lib/rate-limit.js @@ -0,0 +1,19 @@ +const windows = new Map() + +const MAX_ATTEMPTS = 5 +const WINDOW_MS = 60_000 + +export function checkLoginRateLimit(ip) { + const now = Date.now() + const entry = windows.get(ip) + if (!entry || now - entry.start > WINDOW_MS) { + windows.set(ip, { start: now, count: 1 }) + return { allowed: true } + } + entry.count += 1 + if (entry.count > MAX_ATTEMPTS) { + const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) + return { allowed: false, retryAfter } + } + return { allowed: true } +} diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index de8773f..ce870a8 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,6 +1,15 @@ import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' -import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' +import { + comparePassword, + hashPassword, + isAdminEmail, + issueEmailCode, + normalizeEmail, + validatePassword, + verifyEmailCode, +} from '../lib/auth.js' import { prisma } from '../lib/prisma.js' +import { checkLoginRateLimit } from '../lib/rate-limit.js' function mapUserForClient(user) { const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) @@ -64,6 +73,72 @@ export async function registerAuthRoutes(fastify) { return { token, user: mapUserForClient(user) } }) + fastify.post('/api/auth/register', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + const displayNameRaw = request.body?.displayName + const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0] + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' }) + + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const exists = await prisma.user.findUnique({ where: { email } }) + if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) + + const passwordHash = await hashPassword(password) + const user = await prisma.user.create({ + data: { + email, + passwordHash, + displayName: displayName || null, + avatar: null, + avatarStyle: 'avataaars', + }, + }) + + await prisma.notificationPreference.upsert({ + where: { userId: user.id }, + create: { userId: user.id, globalEnabled: true }, + update: {}, + }) + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return reply.code(201).send({ token, user: mapUserForClient(user) }) + }) + + fastify.post('/api/auth/login', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + const ip = request.ip + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' }) + + const rate = checkLoginRateLimit(ip) + if (!rate.allowed) { + return reply + .code(429) + .header('Retry-After', String(rate.retryAfter)) + .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) + } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !user.passwordHash) { + return reply.code(401).send({ error: 'Неверная почта или пароль' }) + } + + const valid = await comparePassword(password, user.passwordHash) + if (!valid) { + return reply.code(401).send({ error: 'Неверная почта или пароль' }) + } + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return { token, user: mapUserForClient(user) } + }) + fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub const user = await prisma.user.findUnique({ where: { id: userId } }) From bb7b40ac45e219045734907cb3eee60a17c8ee4f Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 11:36:11 +0500 Subject: [PATCH 02/23] fix(server): remove all avatarType references after DB column drop --- .../plans/2026-05-22-auth-redesign.md | 1775 +++++++++++++++++ .../specs/2026-05-22-auth-redesign-design.md | 300 +++ server/package-lock.json | 35 + server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/lib/rate-limit.js | 7 + server/src/routes/api/admin-profile.js | 11 - server/src/routes/auth.js | 9 - 7 files changed, 2117 insertions(+), 20 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-22-auth-redesign.md create mode 100644 docs/superpowers/specs/2026-05-22-auth-redesign-design.md diff --git a/docs/superpowers/plans/2026-05-22-auth-redesign.md b/docs/superpowers/plans/2026-05-22-auth-redesign.md new file mode 100644 index 0000000..4e2cf6f --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-auth-redesign.md @@ -0,0 +1,1775 @@ +# Auth Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Переработать аутентификацию: OAuth только email, внутренние аватары, вход по email+паролю, связывание методов входа в ЛК. + +**Architecture:** Server: Fastify + Prisma + bcrypt + in-memory rate limiter. Client: React + Effector + MUI tabs. Email остаётся единым идентификатором. + +**Tech Stack:** Node.js, Fastify, Prisma, bcrypt, React, MUI, Effector, effector-react + +--- + +### Task 1: Install dependencies + +**Files:** +- Modify: `server/package.json` + +- [ ] **Step 1: Install bcrypt** + +```bash +cd server && npm install bcrypt +``` + +- [ ] **Step 2: Verify install** + +```bash +cd server && node -e "require('bcrypt')" +``` + +Expected: no error. + +--- + +### Task 2: Prisma schema — remove avatarType + +**Files:** +- Modify: `server/prisma/schema.prisma` + +- [ ] **Step 1: Remove avatarType from User model** + +Edit `server/prisma/schema.prisma`: remove line `avatarType String?`. + +```diff + avatar String? +- avatarType String? + avatarStyle String? +``` + +- [ ] **Step 2: Add data migration comments to migration** + +The migration SQL must also clean up existing OAuth avatar URLs. After Prisma generates the migration, edit the SQL file to include: + +```sql +-- Before ALTER TABLE DROP COLUMN: +UPDATE User SET avatar = NULL WHERE avatarType = 'oauth'; +``` + +- [ ] **Step 3: Run migration** + +```bash +cd server && npx prisma migrate dev --name remove_avatarType +``` + +Check the generated migration SQL in `server/prisma/migrations/`. Edit it if Prisma didn't include the cleanup SQL. + +Expected: migration runs successfully. + +- [ ] **Step 4: Verify Prisma client regenerated** + +```bash +cd server && node -e "const {prisma} = require('./src/lib/prisma.js'); prisma.user.findFirst().then(u => { console.log('avatarType' in (u||{})); process.exit(0) })" +``` + +Expected: `false` (avatarType not in prisma client). + +--- + +### Task 3: Add password validation and bcrypt helpers to lib/auth.js + +**Files:** +- Modify: `server/src/lib/auth.js` + +- [ ] **Step 1: Add imports and helpers** + +```js +import bcrypt from 'bcrypt' + +const PASSWORD_MIN_LEN = 8 + +const PASSWORD_REGEX = { + letter: /[a-zа-яё]/i, + digit: /[0-9]/, + special: /[^a-zа-яё0-9\s]/i, +} + +export function validatePassword(password) { + if (typeof password !== 'string') return 'Пароль обязателен' + if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов` + if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву' + if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру' + if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол' + return null +} + +export async function hashPassword(password) { + return bcrypt.hash(password, 10) +} + +export async function comparePassword(password, hash) { + return bcrypt.compare(password, hash) +} + +export function isAdminEmail(email) { + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() + if (!adminEmail) return false + return normalizeEmail(email) === adminEmail +} +``` + +--- + +### Task 4: Add in-memory rate limiter for login + +**Files:** +- Create: `server/src/lib/rate-limit.js` + +- [ ] **Step 1: Create rate limiter** + +```js +const windows = new Map() + +const MAX_ATTEMPTS = 5 +const WINDOW_MS = 60_000 + +export function checkLoginRateLimit(ip) { + const now = Date.now() + const entry = windows.get(ip) + if (!entry || now - entry.start > WINDOW_MS) { + windows.set(ip, { start: now, count: 1 }) + return { allowed: true } + } + entry.count += 1 + if (entry.count > MAX_ATTEMPTS) { + const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) + return { allowed: false, retryAfter } + } + return { allowed: true } +} +``` + +- [ ] **Step 2: Test rate limiter** + +```bash +cd server && node -e " +const { checkLoginRateLimit } = require('./src/lib/rate-limit.js'); +checkLoginRateLimit('test1'); checkLoginRateLimit('test1'); +checkLoginRateLimit('test1'); checkLoginRateLimit('test1'); +const r = checkLoginRateLimit('test1'); console.log('5th allowed:', r.allowed); +const r2 = checkLoginRateLimit('test1'); console.log('6th blocked:', !r2.allowed, 'retryAfter:', r2.retryAfter > 0); +" +``` + +Expected: `5th allowed: true`, `6th blocked: true retryAfter: ` + +--- + +### Task 5: Add register endpoint + +**Files:** +- Modify: `server/src/routes/auth.js` + +- [ ] **Step 1: Add POST /api/auth/register** + +Add after existing imports, before `export async function registerAuthRoutes`: + +Add import: +```js +import { hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js' +``` + +Add route inside `registerAuthRoutes`, after the `verify-code` route and before `/api/me`: + +```js +fastify.post('/api/auth/register', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + const displayNameRaw = request.body?.displayName + const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0] + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' }) + + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const exists = await prisma.user.findUnique({ where: { email } }) + if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) + + const passwordHash = await hashPassword(password) + const user = await prisma.user.create({ + data: { + email, + passwordHash, + displayName: displayName || null, + avatar: null, + avatarStyle: 'avataaars', + }, + }) + + await prisma.notificationPreference.upsert({ + where: { userId: user.id }, + create: { userId: user.id, globalEnabled: true }, + update: {}, + }) + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return reply.code(201).send({ token, user: mapUserForClient(user) }) +}) +``` + +--- + +### Task 6: Add login endpoint + +**Files:** +- Modify: `server/src/routes/auth.js` +- Create: `server/src/lib/rate-limit.js` + +- [ ] **Step 1: Add POST /api/auth/login** + +Add import at top of auth.js: +```js +import { comparePassword, isAdminEmail } from '../lib/auth.js' +import { checkLoginRateLimit } from '../lib/rate-limit.js' +``` + +Add route inside `registerAuthRoutes`, after register route: + +```js +fastify.post('/api/auth/login', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + const ip = request.ip + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' }) + + const rate = checkLoginRateLimit(ip) + if (!rate.allowed) { + return reply + .code(429) + .header('Retry-After', String(rate.retryAfter)) + .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) + } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !user.passwordHash) { + return reply.code(401).send({ error: 'Неверная почта или пароль' }) + } + + const valid = await comparePassword(password, user.passwordHash) + if (!valid) { + return reply.code(401).send({ error: 'Неверная почта или пароль' }) + } + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return { token, user: mapUserForClient(user) } +}) +``` + +--- + +### Task 7: Remove avatarType from mapUserForClient and profile routes + +**Files:** +- Modify: `server/src/routes/auth.js` +- Modify: `server/src/routes/api/admin-profile.js` + +- [ ] **Step 1: Remove avatarType from mapUserForClient** + +Edit `mapUserForClient` in `server/src/routes/auth.js`: + +```diff + avatar: user.avatar, +- avatarType: user.avatarType, + avatarStyle: user.avatarStyle, +``` + +Edit the profile PATCH route in same file, remove all `avatarType` handling: + +```diff + const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() + +- if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { +- return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) +- } + if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) +``` + +Also remove `avatarType` from body destructuring and data object: + +```diff +- const avatarTypeRaw = request.body?.avatarType +- const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() + const avatarStyleRaw = request.body?.avatarStyle +``` + +And in data construction: +```diff +- if (avatarType !== undefined) { +- data.avatarType = avatarType === '' ? null : avatarType +- } +``` + +- [ ] **Step 2: Remove avatarType from admin-profile routes** + +Edit `server/src/routes/api/admin-profile.js`: + +Remove `avatarType` from GET `/api/admin/profile` response (line 14): +```diff + avatar: user.avatar, +- avatarType: user.avatarType, + avatarStyle: user.avatarStyle, +``` + +Remove `avatarType` from GET `/api/admin/avatar` response (line 28): +```diff + avatar: user.avatar, +- avatarType: user.avatarType, + avatarStyle: user.avatarStyle, +``` + +Remove `avatarType` handling from PATCH `/api/admin/profile`: +- Remove destructuring lines for `avatarTypeRaw`/`avatarType` (lines 40-41) +- Remove validation check (lines 48-50) +- Remove `avatarType` from data object (lines 60-62) +- Remove `avatarType` from response (line 76) + +--- + +### Task 8: OAuth — remove profile requests, only email + +**Files:** +- Modify: `server/src/routes/oauth-social.js` + +- [ ] **Step 1: Update findOrCreateUserFromOAuth to remove fallback email** + +Replace `findOrCreateUserFromOAuth` function: + +```js +async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) { + const existingLink = await prisma.oAuthAccount.findUnique({ + where: { provider_providerUserId: { provider, providerUserId } }, + include: { user: true }, + }) + if (existingLink?.user) { + if (accessToken !== undefined) { + await prisma.oAuthAccount.update({ + where: { provider_providerUserId: { provider, providerUserId } }, + data: { accessToken }, + }) + } + return existingLink.user + } + + const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : '' + const norm = trimmed ? normalizeEmail(trimmed) : null + + if (linkToUserId) { + if (!norm) return null + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, + }) + return prisma.user.findUnique({ where: { id: linkToUserId } }) + } + + let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null + if (user) { + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, + }) + return user + } + + if (!norm) return null + + user = await prisma.user.create({ + data: { + email: norm, + displayName: norm.split('@')[0], + avatar: null, + avatarStyle: 'avataaars', + }, + }) + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, + }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true }, + }) + return user +} +``` + +- [ ] **Step 2: Update VK callback — remove users.get and profile fields** + +Replace VK callback body after token exchange (from line 115), removing the users.get call and profile field extraction: + +```js +const vkUserId = tokenBody?.user_id +const accessTokenVk = tokenBody?.access_token +const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null + +if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') + +// statePayload already parsed in the state verify block above (see Step 4) +const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined + +const user = await findOrCreateUserFromOAuth({ + provider: 'vk', + providerUserId: String(vkUserId), + accessToken: accessTokenVk ?? null, + suggestedEmail: emailSuggestion, + linkToUserId, +}) + +if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') + +if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`) +} + +const token = await issueUserJwt(fastify, user.id, user.email) +return clientRedirect(fastify, reply, token) +``` + +- [ ] **Step 3: Update Yandex callback — remove profile fields, only email** + +Update the authorize scope (line 179): +```diff +- url.searchParams.set('scope', 'login:email login:info') ++ url.searchParams.set('scope', 'login:email') +``` + +Replace Yandex callback body after `/info` call (from line 230), removing profile field extraction: + +```js +const yaUserId = String(info?.id || '') +if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex') + +const emailGuess = + (Array.isArray(info?.emails) && info.emails[0]) || + info?.default_email || + null + +if (!emailGuess) return oauthErrorRedirect(reply, 'no_email') + +// statePayload already parsed in the state verify block (see Step 4) +const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined + +const user = await findOrCreateUserFromOAuth({ + provider: 'yandex', + providerUserId: yaUserId, + accessToken: yaToken, + suggestedEmail: emailGuess, + linkToUserId, +}) + +if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс') + +if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`) +} + +const token = await issueUserJwt(fastify, user.id, user.email) +return clientRedirect(fastify, reply, token) +``` + +- [ ] **Step 4: Update state verify to parse state payload** + +In VK callback, replace lines 89-93: +```js +let statePayload = null +try { + const raw = typeof query.state === 'string' ? query.state : '' + statePayload = fastify.jwt.verify(raw || '') +} catch { + return oauthErrorRedirect(reply, 'Недействительный state OAuth') +} +``` + +In Yandex callback, replace lines 189-194 the same way: +```js +let statePayload = null +try { + const raw = typeof query.state === 'string' ? query.state : '' + statePayload = fastify.jwt.verify(raw || '') +} catch { + return oauthErrorRedirect(reply, 'Недействительный state OAuth') +} +``` + +--- + +### Task 9: OAuth — add link route + +**Files:** +- Modify: `server/src/routes/oauth-social.js` +- Modify: `server/src/plugins/auth.js` + +- [ ] **Step 1: Add GET /api/auth/oauth/{provider}/link** + +Add route in `registerOAuthSocialRoutes`, after each provider's main route but before the callback: + +```js +fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.VK_CLIENT_ID + const clientSecret = process.env.VK_CLIENT_SECRET + if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` + const state = fastify.jwt.sign( + { oauth: 'vk', action: 'link', userId: request.user.sub }, + { expiresIn: '15m' }, + ) + + const url = new URL('https://oauth.vk.com/authorize') + url.searchParams.set('client_id', clientId) + url.searchParams.set('display', 'page') + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'email') + url.searchParams.set('response_type', 'code') + url.searchParams.set('v', '5.199') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) +}) +``` + +And for Yandex: + +```js +fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.YANDEX_CLIENT_ID + if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` + const state = fastify.jwt.sign( + { oauth: 'yandex', action: 'link', userId: request.user.sub }, + { expiresIn: '15m' }, + ) + + const url = new URL('https://oauth.yandex.ru/authorize') + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'login:email') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) +}) +``` + +--- + +### Task 10: Account linking API — auth-methods, password, unlink + +**Files:** +- Modify: `server/src/routes/auth.js` + +- [ ] **Step 1: Add GET /api/me/auth-methods** + +Add route in `registerAuthRoutes`, after `/api/me`: + +```js +fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { oauthAccounts: { select: { provider: true } } }, + }) + if (!user) return { methods: [] } + + const providers = user.oauthAccounts.map((a) => a.provider) + return { + methods: [ + { type: 'password', active: Boolean(user.passwordHash) }, + { type: 'vk', active: providers.includes('vk') }, + { type: 'yandex', active: providers.includes('yandex') }, + ], + } +}) +``` + +- [ ] **Step 2: Add POST /api/me/password** + +Add route after auth-methods: + +```js +fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' }) + + const password = String(request.body?.password || '') + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(password) + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) + + return { ok: true } +}) +``` + +- [ ] **Step 3: Add DELETE /api/me/oauth/{provider}** + +Add route after /api/me/password: + +```js +fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const provider = request.params?.provider + + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' }) + } + if (provider !== 'vk' && provider !== 'yandex') { + return reply.code(400).send({ error: 'Неизвестный провайдер' }) + } + + const oauth = await prisma.oAuthAccount.findFirst({ + where: { userId, provider }, + }) + if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' }) + + const remainingOAuth = await prisma.oAuthAccount.count({ + where: { userId, provider: { not: provider } }, + }) + const user = await prisma.user.findUnique({ where: { id: userId }, select: { passwordHash: true } }) + if (!user?.passwordHash && remainingOAuth === 0) { + return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' }) + } + + await prisma.oAuthAccount.delete({ where: { id: oauth.id } }) + return { ok: true } +}) +``` + +--- + +### Task 11: Server tests — password auth endpoints + +**Files:** +- Create: `server/src/routes/__tests__/auth-password.test.js` + +- [ ] **Step 1: Write tests** + +```js +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { prisma } from '../../lib/prisma.js' +import { registerAuthRoutes } from '../auth.js' + +const JWT_SECRET = 'test-secret' +const TEST_EMAIL = `test-reg-${Date.now()}@example.com` + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(jwt, { secret: JWT_SECRET }) + app.decorate('authenticate', async function (request, reply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', { emit: () => {} }) + await registerAuthRoutes(app) + await app.ready() + return app +} + +describe('POST /api/auth/register', () => { + let app + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }) + }) + + it('registers a new user with password', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(201) + const body = JSON.parse(res.body) + expect(body.token).toBeTruthy() + expect(body.user.email).toBe(TEST_EMAIL) + expect(body.user.displayName).toBe('test-reg') + }) + + it('rejects duplicate email', async () => { + await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(409) + }) + + it('rejects weak password — too short', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Ab1!' }, + }) + expect(res.statusCode).toBe(400) + const body = JSON.parse(res.body) + expect(body.error).toContain('не менее 8') + }) + + it('rejects weak password — no digit', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Abcdefgh!' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('цифру') + }) + + it('rejects weak password — no special char', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Abcdefg1' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('спецсимвол') + }) +}) + +describe('POST /api/auth/login', () => { + let app + const loginEmail = `test-login-${Date.now()}@example.com` + + beforeAll(async () => { + app = await buildApp() + await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: loginEmail, password: 'Test123!@' }, + }) + }) + afterAll(async () => { + await prisma.user.deleteMany({ where: { email: loginEmail } }) + await app.close() + }) + + it('logs in with correct password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: loginEmail, password: 'Test123!@' }, + headers: { 'x-forwarded-for': '1.1.1.1' }, + }) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body).token).toBeTruthy() + }) + + it('rejects wrong password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: loginEmail, password: 'Wrong!!1!' }, + headers: { 'x-forwarded-for': '2.2.2.2' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('rejects non-existent email', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: 'nobody@nowhere.test', password: 'Test123!@' }, + headers: { 'x-forwarded-for': '3.3.3.3' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('returns 403 for admin email', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: process.env.ADMIN_EMAIL || 'admin@test.local', password: 'Test123!@' }, + headers: { 'x-forwarded-for': '4.4.4.4' }, + }) + if (process.env.ADMIN_EMAIL) { + expect(res.statusCode).toBe(403) + } + }) +}) +``` + +- [ ] **Step 2: Run tests** + +```bash +cd server && npx vitest run src/routes/__tests__/auth-password.test.js +``` + +Expected: all tests pass. + +--- + +### Task 12: Server tests — auth-methods, password, unlink + +**Files:** +- Create: `server/src/routes/__tests__/auth-methods.test.js` + +- [ ] **Step 1: Write tests** + +```js +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { prisma } from '../../lib/prisma.js' +import { registerAuthRoutes } from '../auth.js' + +const JWT_SECRET = 'test-secret' + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(jwt, { secret: JWT_SECRET }) + app.decorate('authenticate', async function (request, reply) { + try { await request.jwtVerify() } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', { emit: () => {} }) + await registerAuthRoutes(app) + await app.ready() + return app +} + +function signToken(app, userId, email) { + return app.jwt.sign({ sub: userId, email }) +} + +async function createUser(email) { + const user = await prisma.user.create({ + data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' }, + }) + await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } }) + return user +} + +describe('GET /api/me/auth-methods', () => { + let app, user, token + const email = `test-methods-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + + beforeEach(async () => { + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('returns methods for user without any method', async () => { + const res = await app.inject({ + method: 'GET', url: '/api/me/auth-methods', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) + expect(body.methods.find((m) => m.type === 'password').active).toBe(false) + expect(body.methods.find((m) => m.type === 'vk').active).toBe(false) + expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false) + }) + + it('returns password as active after setting it', async () => { + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) + const res = await app.inject({ + method: 'GET', url: '/api/me/auth-methods', + headers: { authorization: `Bearer ${token}` }, + }) + expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true) + }) +}) + +describe('POST /api/me/password', () => { + let app, user, token + const email = `test-set-pw-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + + beforeEach(async () => { + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('sets password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/me/password', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(200) + + const u = await prisma.user.findUnique({ where: { id: user.id } }) + expect(u.passwordHash).toBeTruthy() + }) + + it('rejects if password already set', async () => { + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } }) + const res = await app.inject({ + method: 'POST', url: '/api/me/password', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(409) + }) +}) + +describe('DELETE /api/me/oauth/:provider', () => { + let app, user, token + const email = `test-unlink-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + + beforeEach(async () => { + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('returns 404 for non-linked provider', async () => { + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('unlinks a provider', async () => { + await prisma.oAuthAccount.create({ + data: { provider: 'vk', providerUserId: '123', userId: user.id }, + }) + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + + const count = await prisma.oAuthAccount.count({ where: { userId: user.id } }) + expect(count).toBe(0) + }) + + it('rejects removing last method without password', async () => { + await prisma.oAuthAccount.create({ + data: { provider: 'vk', providerUserId: '123', userId: user.id }, + }) + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('последний метод') + }) +}) +``` + +- [ ] **Step 2: Run tests** + +```bash +cd server && npx vitest run src/routes/__tests__/auth-methods.test.js +``` + +Expected: all tests pass. + +--- + +### Task 13: Run all server tests together + +- [ ] **Step 1: Run full server test suite** + +```bash +cd server && npx vitest run +``` + +Expected: all existing and new tests pass. + +--- + +### Task 14: Client — update Effector auth model + +**Files:** +- Modify: `client/src/shared/model/auth.ts` + +- [ ] **Step 1: Remove avatarType from AuthUser type and add new effects** + +```ts +import { createEffect, createEvent, createStore, sample } from 'effector' +import { apiClient } from '@/shared/api/client' +import { createErrorStore } from '@/shared/lib/create-error-store' +import { persistToken } from '@/shared/lib/persist-token' + +export type AuthUser = { + id: string + email: string + displayName?: string | null + firstName?: string | null + lastName?: string | null + gender?: string | null + avatar?: string | null + avatarStyle?: string | null + isAdmin?: boolean +} + +export type AuthMethod = { + type: 'password' | 'vk' | 'yandex' + active: boolean +} + +export const tokenSet = createEvent() +export const logout = createEvent() + +// ----- Token persistence ----- + +const persistTokenFx = createEffect({ + handler: (token) => persistToken(token), +}) + +export const $token = createStore(null) + .on(tokenSet, (_, t) => t) + .reset(logout) + +sample({ clock: $token, target: persistTokenFx }) + +// ----- User ----- + +export const $user = createStore(null).reset(logout) + +export const meFx = createEffect(async (token: string) => { + const { data } = await apiClient.get<{ user: AuthUser | null }>('me', { + headers: { Authorization: `Bearer ${token}` }, + }) + return data.user +}) + +sample({ clock: tokenSet, filter: (t): t is string => Boolean(t), target: meFx }) + +sample({ clock: meFx.doneData, target: $user }) + +// ----- Email change ----- + +export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => { + await apiClient.post('me/change-email/request-code', { newEmail }) +}) + +export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => { + const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params) + return data.user +}) + +// ----- Profile update ----- + +export type UpdateProfileParams = { + displayName: string | null + avatar?: string | null + avatarStyle?: string | null +} + +export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => { + const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) + return data.user +}) + +// ----- Login / Register ----- + +export const loginFx = createEffect(async (params: { email: string; password: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/login', params) + tokenSet(data.token) + return data.user +}) + +export const registerFx = createEffect( + async (params: { email: string; password: string; displayName?: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) + tokenSet(data.token) + return data.user + }, +) + +// ----- Auth methods ----- + +export const fetchAuthMethodsFx = createEffect(async () => { + const { data } = await apiClient.get<{ methods: AuthMethod[] }>('me/auth-methods') + return data.methods +}) + +export const setPasswordFx = createEffect(async (password: string) => { + await apiClient.post('me/password', { password }) +}) + +export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => { + await apiClient.delete(`me/oauth/${provider}`) +}) + +// ----- Error stores ----- + +export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error +export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error +export const $updateProfileError = createErrorStore(updateProfileFx).$error + +// ----- Re-exports ----- + +export { readStoredToken } from '@/shared/lib/persist-token' + +// ----- Sync user from profile/email changes ----- + +sample({ clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData], target: $user }) +``` + +--- + +### Task 15: Client — update UserAvatar (remove avatarType) + +**Files:** +- Modify: `client/src/shared/ui/UserAvatar.tsx` + +- [ ] **Step 1: Remove avatarType prop and always use DiceBear fallback** + +```tsx +import { useMemo } from 'react' +import Avatar from '@mui/material/Avatar' +import type { SxProps, Theme } from '@mui/material/styles' +import { createAvatar } from '@dicebear/core' +import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' + +type UserAvatarProps = { + userId: string + avatarUrl?: string | null + avatarStyle?: string | null + size?: number + sx?: SxProps +} + +export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { + const generatedSrc = useMemo(() => { + const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) + const avatar = createAvatar(styleDef.style, { seed: userId }) + return avatar.toDataUri() + }, [userId, avatarStyle]) + + const src = avatarUrl || generatedSrc + + return ( + + ? + + ) +} +``` + +--- + +### Task 16: Client — update UserAvatar usages (remove avatarType prop) + +- [ ] **Step 1: Find and update all UserAvatar usages** + +Search for all `avatarType` passed to `UserAvatar` and remove them. Files to modify: + +- `client/src/pages/me/ui/sections/SettingsPage.tsx` — lines 131, 148 (remove `avatarType` prop) +- `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` — lines 147, 164 (remove `avatarType` prop) +- `client/src/features/user/user-menu/ui/UserMenu.tsx` — check for UserAvatar usage +- Any other files using UserAvatar with avatarType + +Remove `avatarType` prop from each `` usage. The prop no longer exists on the component. + +--- + +### Task 17: Client — rewrite AuthPage with tabs + +**Files:** +- Modify: `client/src/pages/auth/ui/AuthPage.tsx` + +- [ ] **Step 1: Rewrite complete AuthPage** + +```tsx +import { useEffect, useState } from 'react' +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 Tab from '@mui/material/Tab' +import Tabs from '@mui/material/Tabs' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { useForm } from 'react-hook-form' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { OAuthButtons } from '@/features/auth-oauth' +import { apiClient } from '@/shared/api/client' +import { $user, loginFx, registerFx, tokenSet } from '@/shared/model/auth' + +type AuthResponse = { + token: string + user: { + id: string + email: string + displayName?: string | null + avatar?: string | null + avatarStyle?: string | null + } +} + +function getApiErrorMessage(err: unknown): string | null { + if (!err || typeof err !== 'object') return null + const anyErr = err as Record + const response = anyErr.response as Record | undefined + const data = response?.data as Record | undefined + const msg = data?.error + return typeof msg === 'string' ? msg : null +} + +export function AuthPage() { + const [message, setMessage] = useState(null) + const [oauthError, setOauthError] = useState(null) + const [tab, setTab] = useState(0) + const [isRegister, setIsRegister] = useState(false) + const [searchParams, setSearchParams] = useSearchParams() + const navigate = useNavigate() + const user = useUnit($user) + + const { register, watch } = useForm<{ + email: string + password: string + passwordConfirm: string + displayName: string + code: string + }>({ + defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' }, + mode: 'onChange', + }) + + const email = watch('email') + const password = watch('password') + const passwordConfirm = watch('passwordConfirm') + const code = watch('code') + + useEffect(() => { + if (user) navigate('/', { replace: true }) + }, [navigate, user]) + + useEffect(() => { + const err = searchParams.get('oauthError') + if (!err) return + setOauthError(err) + setSearchParams({}, { replace: true }) + }, [searchParams, setSearchParams]) + + const loginMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/login', { email, password }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const registerMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/register', { + email, + password, + displayName: watch('displayName') || undefined, + }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const requestCode = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/request-code', { email }) + }, + onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'), + }) + + const verifyCode = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/verify-code', { email, code }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const errMsg = + getApiErrorMessage(loginMutation.error) || + getApiErrorMessage(registerMutation.error) || + getApiErrorMessage(requestCode.error) || + getApiErrorMessage(verifyCode.error) + + const passwordError = + isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + + return ( + + + Вход / регистрация + + + {message && {message}} + {oauthError && ( + setOauthError(null)}>{oauthError} + )} + {errMsg && {errMsg}} + + setTab(v)} sx={{ mb: 3 }}> + + + + + + {tab === 0 && ( + + + + + + + + + {isRegister && ( + + )} + + + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} + + )} + + {tab === 1 && ( + + + + + + + + + )} + + {tab === 2 && ( + + + + )} + + ) +} +``` + +--- + +### Task 18: Client — add auth methods section to SettingsPage + +**Files:** +- Modify: `client/src/pages/me/ui/sections/SettingsPage.tsx` + +- [ ] **Step 1: Simplify avatar section — remove OAuth avatar switching** + +Replace the avatar section's state variables (lines 59-61): +```tsx +const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') +const useOAuth = user?.avatarType === 'oauth' +const useGenerated = user?.avatarType === 'generated' +``` + +With: +```tsx +// no more avatarType — always internal avatars +``` + +And replace the caption (lines 140): +```diff +- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} ++ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} +``` + +Remove the "Use OAuth" button block entirely (lines 208-222): +```diff +- {hasOAuthAvatar && !hasUnsavedPreview && ( +- +- )} +``` + +And in UserAvatar usage, remove `avatarType` prop (lines 131, 148): +```diff +- avatarType={hasUnsavedPreview ? 'generated' : user.avatarType} +``` +(just delete that prop line) + +Remove `avatarType` from updateProfileFx calls (lines 191-197 and any other): +```diff + updateProfileFx({ + displayName: user.displayName?.trim() || null, + avatar: previewSrc, +- avatarType: 'generated', + avatarStyle: previewStyle, + }) +``` + +- [ ] **Step 2: Add auth methods section — imports and state** + +Add imports at top: +```tsx +import { useCallback } from 'react' +import Chip from '@mui/material/Chip' +import { + fetchAuthMethodsFx, + setPasswordFx, + unlinkOAuthFx, + type AuthMethod, +} from '@/shared/model/auth' +``` + +Add state and data loading after existing hooks: +```tsx +const [authMethods, setAuthMethods] = useState([]) +const [showSetPassword, setShowSetPassword] = useState(false) +const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ + defaultValues: { password: '', passwordConfirm: '' }, +}) + +useEffect(() => { + fetchAuthMethodsFx().then(setAuthMethods).catch(() => { + setAuthMethods([]) + }) +}, []) + +const setPasswordMutation = useMutation({ + mutationFn: async (pw: string) => { + await setPasswordFx(pw) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + setShowSetPassword(false) + }, + onError: () => {}, +}) + +const unlinkMutation = useMutation({ + mutationFn: async (provider: 'vk' | 'yandex') => { + await unlinkOAuthFx(provider) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + }, + onError: () => {}, +}) + +const linkedCount = useCallback(() => { + return authMethods.filter((m) => m.active).length +}, [authMethods]) + +const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } +``` + +- [ ] **Step 3: Add auth methods section UI** + +Insert after the avatar section's closing `` + `` (before email change section), but only if `!user.isAdmin`: + +```tsx +{!user.isAdmin && ( + <> + + + + Методы входа + + + {authMethods.map((m) => ( + + {METHOD_LABELS[m.type] || m.type} + + {m.active && m.type !== 'password' && ( + + )} + {!m.active && m.type === 'password' && ( + + )} + {!m.active && m.type !== 'password' && ( + + )} + + ))} + + + {showSetPassword && ( + + + + + + + + + )} + + +)} +``` + +--- + +### Task 19: Client — update AdminSettingsPage (remove avatarType) + +**Files:** +- Modify: `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` + +- [ ] **Step 1: Remove avatarType and OAuth avatar references** + +The AdminSettingsPage mirrors SettingsPage. Make these specific changes: + +1. Remove state lines (find the equivalent of `hasOAuthAvatar`, `useOAuth`, `useGenerated`): +```diff +- const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') +- const useOAuth = user?.avatarType === 'oauth' +- const useGenerated = user?.avatarType === 'generated' +``` + +2. Remap the caption line: +```diff +- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} ++ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} +``` + +3. Remove the "Use OAuth" button block (find lines starting with `{hasOAuthAvatar && !hasUnsavedPreview`). + +4. Remove `avatarType` prop from all `` usages in this file. + +5. Remove `avatarType` from any `updateProfileFx` or admin profile API calls in this file. Find the PATCH call payload and remove the `avatarType` field. + +--- + +### Task 20: Client tests + +**Files:** +- Create: `client/src/pages/auth/__tests__/AuthPage.test.tsx` + +- [ ] **Step 1: Write AuthPage tests** + +```tsx +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { AuthPage } from '../ui/AuthPage' + +vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => null } +}) + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('AuthPage', () => { + it('renders three tabs', () => { + renderPage() + expect(screen.getByText('Пароль')).toBeTruthy() + expect(screen.getByText('Код')).toBeTruthy() + expect(screen.getByText('Другой способ')).toBeTruthy() + }) + + it('shows login form by default on tab 0', () => { + renderPage() + expect(screen.getByText('Вход')).toBeTruthy() + expect(screen.getByText('Регистрация')).toBeTruthy() + const buttons = screen.getAllByRole('button') + const loginBtn = buttons.find((b) => b.textContent === 'Войти') + expect(loginBtn).toBeTruthy() + }) + + it('switches to register form', () => { + renderPage() + fireEvent.click(screen.getByText('Регистрация')) + expect(screen.getByText('Зарегистрироваться')).toBeTruthy() + }) + + it('switches to code tab', () => { + renderPage() + fireEvent.click(screen.getByText('Код')) + expect(screen.getByText('Отправить код')).toBeTruthy() + }) + + it('switches to OAuth tab', () => { + renderPage() + fireEvent.click(screen.getByText('Другой способ')) + expect(screen.getByText('Войти через VK ID')).toBeTruthy() + expect(screen.getByText('Войти через Яндекс ID')).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run client tests** + +```bash +cd client && npx vitest run src/pages/auth/__tests__/AuthPage.test.tsx +``` + +Expected: all 5 tests pass. + +--- + +### Task 21: Run full test suite + +- [ ] **Step 1: Run server tests** + +```bash +cd server && npx vitest run +``` + +- [ ] **Step 2: Run client tests** + +```bash +cd client && npx vitest run +``` + +- [ ] **Step 3: Run client lint + format check** + +```bash +cd client && npm run lint && npm run format:check +``` + +- [ ] **Step 4: Run client build** + +```bash +cd client && npm run build +``` + +All must pass. diff --git a/docs/superpowers/specs/2026-05-22-auth-redesign-design.md b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md new file mode 100644 index 0000000..af2cad9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md @@ -0,0 +1,300 @@ +# Auth Redesign — Spec + +**Date:** 2026-05-22 +**Goal:** Переработать систему аутентификации: OAuth запрашивает только email, убрать внешние аватары, добавить вход по email+паролю, дать пользователям связывать методы входа в ЛК. + +--- + +## 1. Data Model (Prisma) + +### 1.1. Модель `User` — изменения + +| Поле | Было | Стало | +|------|------|-------| +| `passwordHash` | `String?` (не использовалось) | Задействуем. Хранит bcrypt-хеш. `null` если пароль не установлен. | +| `avatarType` | `String?` (`'oauth'` / `'generated'`) | **Удалить.** Все аватары внутренние (DiceBear). | +| `avatar` | `String?` (URL или data:uri) | Только DiceBear URL или `null` (генерируется на лету) | +| `avatarStyle` | `String?` | Без изменений. | + +Остальные поля (`id`, `email`, `displayName`, `firstName`, `lastName`, `gender`, `createdAt`, `updatedAt`) — без изменений. `firstName`, `lastName`, `gender` больше не заполняются при OAuth, но остаются в БД (могут быть заполнены пользователем вручную позже). + +### 1.2. Модель `OAuthAccount` — без изменений + +```prisma +model OAuthAccount { + id String @id @default(cuid()) + provider String // 'vk' | 'yandex' + providerUserId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? // зарезервировано, не используется сейчас + createdAt DateTime @default(now()) + + @@unique([provider, providerUserId]) +} +``` + +### 1.3. Модель `AuthCode` — без изменений + +### 1.4. Миграции + +1. Удаление колонки `avatarType` из `User` +2. Миграция данных: для всех пользователей с `avatarType = 'oauth'` установить `avatar = null` (внешние URL больше не используются, аватар перегенерируется DiceBear) + +--- + +## 2. Авторизация по email+паролю + +### 2.1. Регистрация + +`POST /api/auth/register` (новый, без аутентификации) + +**Request:** +```json +{ + "email": "user@example.com", + "password": "Abcdef1!", + "displayName": "Иван" // optional +} +``` + +**Валидация:** +- `email`: валидный email, нормализация (trim + lowercase), уникальность +- `password`: минимум 8 символов, минимум 1 буква, 1 цифра, 1 спецсимвол +- `displayName`: опционально, строка до 100 символов. Если не передан — берётся часть email до `@` + +**Логика:** +1. Проверка, что email не занят → 409 если занят +2. `passwordHash = await bcrypt.hash(password, 10)` +3. Создание пользователя: `email`, `passwordHash`, `displayName`, `avatar = null`, `avatarStyle = 'avataaars'` +4. Создание `NotificationPreference` (как сейчас в verify-code) +5. Возврат JWT + user + +**Response 201:** +```json +{ + "token": "jwt...", + "user": { "id", "email", "displayName", "avatar", "avatarStyle", "isAdmin": false } +} +``` + +### 2.2. Вход + +`POST /api/auth/login` (новый, без аутентификации) + +**Request:** +```json +{ + "email": "user@example.com", + "password": "Abcdef1!" +} +``` + +**Rate limit:** максимум 5 попыток в минуту с одного IP (использовать `@fastify/rate-limit`). При превышении — `429 Too Many Requests`. + +**Логика:** +1. Нормализация email +2. Поиск пользователя по email +3. Если пользователь не найден ИЛИ `passwordHash === null` → `401 Invalid email or password` (одинаковый ответ для безопасности) +4. `await bcrypt.compare(password, user.passwordHash)` → если не совпадает → `401` +5. Возврат JWT + user + +### 2.3. Админ и пароль + +- Админ (`email === ADMIN_EMAIL`) **не может** зарегистрироваться или войти по паролю +- `POST /api/auth/register` и `POST /api/auth/login` возвращают `403` для админского email +- Админ также **не может** установить пароль через `POST /api/me/password` + +--- + +## 3. OAuth (только email) + +### 3.1. Scope + +| Провайдер | Было | Стало | +|-----------|------|-------| +| VK | `email` | `email` (без изменений, но больше не запрашиваем профиль) | +| Яндекс | `login:email login:info` | `login:email` | + +### 3.2. Callback — что убираем + +**VK:** +- Больше не делаем `users.get` после получения токена +- Не сохраняем: `first_name`, `last_name`, `photo_200`, `sex` + +**Яндекс:** +- Всё ещё вызываем `GET https://login.yandex.ru/info` (нужен для получения email) +- Из ответа берём только `default_email` или первый из `emails` +- **Не сохраняем:** `first_name`, `last_name`, `display_name`, `sex`, `default_avatar_id` + +### 3.3. Callback — новая логика + +``` +1. Обмен code на access_token (как сейчас) +2. Извлечение email из ответа провайдера: + - VK: поле `email` в ответе access_token + - Яндекс: вызываем `/info`, из ответа берём `default_email` или первый из `emails` +3. Если email отсутствует → редирект с ?oauthError=no_email +4. Нормализация email +5. Поиск пользователя по email: + a) Найден → привязываем OAuthAccount (если ещё не привязан), возвращаем JWT + b) Не найден → создаём нового: + - email + - displayName = часть email до @ + - avatar = null + - avatarStyle = 'avataaars' + - Создаём OAuthAccount + - Создаём NotificationPreference + - Возвращаем JWT +6. Редирект на CLIENT_PUBLIC_URL/auth/callback?token=... +``` + +**Fallback-email `{provider}_{id}@oauth.craftshop.local` — убираем.** Если провайдер не дал email — ошибка. + +### 3.4. State-параметр + +Без изменений. JWT с `expiresIn: 15m` для CSRF-защиты. + +--- + +## 4. Связывание аккаунтов + +### 4.1. Авто-связывание + +При OAuth-входе: если email из OAuth совпадает с email существующего пользователя — автоматически создаётся `OAuthAccount`, связывающий провайдера с пользователем. Вход происходит мгновенно. + +### 4.2. Ручное связывание — страница настроек `/me` + +**Получение статуса методов:** `GET /api/me/auth-methods` (новый, требует `authenticate`) + +**Response:** +```json +{ + "methods": [ + { "type": "password", "active": true }, + { "type": "vk", "active": false }, + { "type": "yandex", "active": true } + ] +} +``` + +Логика: +- `type: "password"` — `active: user.passwordHash !== null` +- `type: "vk"` / `type: "yandex"` — `active: exists(OAuthAccount)` + +### 4.3. Привязка OAuth + +`GET /api/auth/oauth/{provider}/link` (новый, требует `authenticate`) + +1. Генерирует state-JWT с `{ userId, provider, action: 'link' }`, `expiresIn: 15m` +2. Редиректит на страницу авторизации провайдера +3. После callback проверяет `action: 'link'` в state +4. Создаёт `OAuthAccount` для указанного `userId` (нормальный upsert) +5. Редиректит на `/me?linked={provider}` + +### 4.4. Привязка пароля + +`POST /api/me/password` (новый, требует `authenticate`) + +**Request:** `{ "password": "Abcdef1!" }` + +**Логика:** +1. Если `user.email === ADMIN_EMAIL` → `403` +2. Если `user.passwordHash !== null` → `409 Password already set` (для смены использовать отдельный метод) +3. Валидация пароля (8+, буква+цифра+спецсимвол) +4. `user.passwordHash = await bcrypt.hash(password, 10)` +5. Сохранение пользователя + +### 4.5. Отвязывание + +`DELETE /api/me/oauth/{provider}` (новый, требует `authenticate`) + +**Логика:** +1. Удаление `OAuthAccount` по `userId + provider` +2. Если `OAuthAccount` не найден → `404` +3. **Проверка последнего метода:** после удаления, если у пользователя нет ни `passwordHash`, ни других `OAuthAccount` → `400 Cannot remove last auth method` +4. Возврат `200` + +**Примечание:** отвязывание пароля (установка `passwordHash = null`) пока не делаем — можно добавить позже. + +### 4.6. Админ и связывание + +- `POST /api/me/password` → `403` для админа +- OAuth-привязка через `/link` → `403` для админа +- OAuth-отвязывание → `403` для админа + +--- + +## 5. Email-код (без изменений логики) + +`POST /api/auth/request-code` и `POST /api/auth/verify-code` работают как раньше, изменений нет. Админ входит только этим способом. + +--- + +## 6. Изменения на клиенте + +### 6.1. Страница `/auth` + +**3 вкладки:** +- **«Пароль»** — переключатель Вход/Регистрация. Вход: email + пароль. Регистрация: email + пароль + подтверждение пароля + имя (опционально). +- **«Код»** — как сейчас: email → отправить код → ввести код. +- **«Другой способ»** — кнопки Войти через VK / Яндекс. + +### 6.2. Страница `/me` (настройки) + +Новая секция «Методы входа»: +- Список методов с индикаторами «привязан» / «не привязан» +- Кнопки «Привязать» (редирект на OAuth или форма пароля) +- Кнопки «Отвязать» (disabled если это последний метод) +- Для админа — секция скрыта + +### 6.3. Effector-стейт + +- `$token`, `$user`, `tokenSet`, `logout` — без изменений +- Добавить эффекты: `loginFx`, `registerFx`, `linkOAuthFx`, `setPasswordFx`, `unlinkOAuthFx` + +### 6.4. Компоненты + +- `UserAvatar` — убрать проверку `avatarType`, всегда использовать DiceBear (сохранённый `avatar` или генерация на лету) +- `OAuthButtons` — без изменений (URL те же) + +--- + +## 7. Тестирование + +### 7.1. Серверные тесты + +- `POST /api/auth/register` — успешная регистрация, дубликат email, слабый пароль +- `POST /api/auth/login` — успешный вход, неверный пароль, несуществующий email, превышение rate limit +- OAuth callback — создание нового пользователя с email, авто-связывание по email, ошибка при отсутствии email от провайдера +- `POST /api/me/password` — установка, повторная установка (409), админ (403) +- `GET /api/me/auth-methods` — корректный список методов +- `DELETE /api/me/oauth/{provider}` — отвязывание, последний метод (400), админ (403) +- Админ не может войти через `/login` (403) + +### 7.2. Клиентские тесты + +- Страница `/auth` — наличие трёх вкладок, переключение +- Форма регистрации — валидация пароля, подтверждение +- Форма входа — обработка ошибок +- `/me` — отображение методов, кнопки привязки/отвязки + +--- + +## 8. Миграция существующих пользователей + +1. Все пользователи с `avatarType = 'oauth'`: `avatar = null`, `avatarType = null`. Аватар перегенерируется DiceBear при следующем отображении. +2. `avatarType` колонка удаляется из БД. +3. Существующие OAuth-аккаунты работают как раньше, но при следующем входе через OAuth обновляется логика (не запрашиваем профиль). +4. `firstName`, `lastName`, `gender` у существующих OAuth-пользователей остаются в БД (не удаляем, просто больше не пополняем из OAuth). + +--- + +## 9. Заметки + +- **bcrypt** — не установлен, нужно добавить `npm install bcrypt` в server. +- **Rate limit** — `@fastify/rate-limit` не установлен. Добавить или реализовать самодельный in-memory rate limiter (5 попыток/мин/IP). +- Все новые эндпоинты должны валидироваться через JSON Schema (как существующие). +- Пароль никогда не возвращается в ответах API и не логируется. +- Существующий `User.passwordHash` (null у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать. diff --git a/server/package-lock.json b/server/package-lock.json index 1186824..48993e2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,6 +13,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", + "bcrypt": "^6.0.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", "nodemailer": "^8.0.7", @@ -2130,6 +2131,29 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3605,6 +3629,17 @@ "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemailer": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 53dd97bacd3388780f328cfc0585c677a068f734..e8e8ce5498e9b3fb8a44fca779fbea1701b91a6d 100644 GIT binary patch delta 1874 zcma)7e`pg|9KUz(rESur*RrL>)sk3$RH>c!Be~r9V@cJzmDx7hl&OgElDqscNt3p* zCfOgY-TX%wD1jZ0G25ZIxjEZLbDg*`98QLrbA!ng1`a_`*$}q*uX~raAT)!$f77l=bB055%z;`ILlkac@0#b(<;-wCW5qTlRDRMw$Yz40UR~g(Z zG=IPGxp;#y)(=^SoqoSM%XJTR~7Xu&lO1C29O5`>egNao5&=3>I>PCU#LE%Uy z&hinO$3{wNd(i`ar)*{OWvJa?^mg@b@9urw@9o^%)79%+F!F-OV~sitKa61{XF8E~ zgvFrC8ORiG18{#O-kLh{06|@_{9^gg{Fixc?WJ1M__mR+nXIxJ9vY632eehw;QhgfSpnZFp`~zvBugDKb(zQ{gqo76WE|5D&4SYkwNfV&15Y!baO1+Zb zWnZiD2jRPvZ#%HK8SE1`gMlFQHAa|#7{$TS@t_oqIxXh1$2^p*@Ank%Tzr@$y7j&~_CD@h?C6mYtf022%ectnu(&VY#bXpip> zOS3%fWLX={vkb%fhm}NfRPjrrQd%1Jr?V-gY&WOfD8UGeIfD6k2xq~m^D*wqB!{xD zv^w`3uI|xEhK;cA7_;&^O5Gm^ytUA}0?gf~xi$u34!w z7(-(HpESRON)%T)EOI$?FV@Er2ikKA-Ltjnq!(H2JfxHUFrN{UnV}%Vat^2N_~xB? zU7Q4usd9xZH7lgXOMvBD2bClKfe~fc&n$j#077Hw9mwOi+CIfubuE{u~)_u(PjTW$+Om57dy{Tx^VGQ=?Y`AUc4 zquE@Hk7jdGx_>ve({Cfa%_DBDc%E0hB|J(-N~Ge-!qKOc+&ZO7?&58{-evt5au=4} hSbSInEG{f2&2b->O>r74N98N##oAeVx12GY_!lmgID`NI delta 8997 zcmai3TWlTIb>)4DBK5eEZHabNSrl!Vj^vr%dEbfJO0=c4ibFkYIz~|Fd89Po znVWZJT2IfPbw(r)v+_ zerW%e?|-%Yv2ynA_bc~io4#M(o*h{%Z(rEGc73+}dbz(m`Pjnlfyr|6v8*v&+P?he z_sSnHuY0vG8rtU^TQfAaZDI4^-3u=dY+Al+sr>V5udlDXG&DY1`pn+x3#U8J9J(+y zbF_7)^Kf^jeQt90=~Lb5xwD80|v>ZzI2_VwN0FAZluSgiDC7naNSXD6>$)@A>Ez4Fx3o8_;Smwr&LULEY) z)Yn)4W_`N;k%eQ!4=%?iE1#`T{7OpRmgxIC&$Q0Zb~|o+=Dg_mSN0x6-bwtw)_+ug z84324dN%oc)wS8TdzHN+>#dw$dahDxjP^g@H?ZcHef<+_e!2c1*Z<}EXx(=Q-yU2T z+}Zzp^`-I`%a4{`E)Djbg~j`q&%RoDzLIUaQ2NO7)o)dQUN)Yd%>Mm)*{vhqFAk+? z>5q;H;q2He)vej4?^L#e!0)|O{eu$W`04V_H!81}mp}bld9-rJ1{hsi|5ji9cQ#(z zIIy9;{;kE$jqB@|-+H@p)~D4S|1450VmhB|v@cIxeD*^2`UjO=TzRK&!@`FB>vj$P+raL% zU#MLh{K~+8_q{XlcI`yvH|jqrFP4uY=C9rThjVB8hqi1feeRIL`ovu45}zb1eWUur z?4Q0-of}2Uw=TElTbCxT%yveGCk{S2u`=}d{FQD#wtVEzs_QD%C#Kg7WuJbj`uOtC z7AxDT<7oJeD6Tu~VAwRr?Lw=0oJaU2te1<5jfyj8y zG4td>t4URQ6;?qw_B7jA4Mjo;jzqh9!cd_z51}Dc*x@QS15VKZW_Zo6huJoKk|$K+ zIU!WT3IGJ*7{NYHqoGtEx)7AuNf>Q7=fyycR@(cH6$3exU{{pwKVI9CE$y$}k+~DK zjlJ`UM@>0np-n;>ZHPpp~(s9ZB5s62fTj zd~XjvHc7=W2I6Q_K@DDUsSiBSNE}*0CiwW`E|FL-Emtq?8pYd=+%ft&@)AH|6JP)# zfFM}XLne?D2tFuDQX>+Wi=CXP2raf?jBw5jrC?Fbo4dw${vX2vh`Y%n8?Jm!CN zV%(1v<2Y{1f9w_O2mxS0mN*Acfna+|(3u0=B4rqnAgp6PD>&#kth@5#ZYbcGN69j@O zm__jMPMw%^0<@@xc!Zkr%!~rR=pq0MnsX|5`0_!bqz(RX22A4+IL0w>4(|q2oTkLG3an%$6-1Vxn55x= z*z0OL3Or@OZc0JJFY1hUEFt_zQ#O$R%f^n^hO+lRU+d4l zu)emncRsda=oh4f4OHTVZS8`Q+J~b|DncZdyd?p#4rPn0%|IlJ#L%!dTs5a}=BL#| zf`DBxyCgO*;kewFxy6jk<1m3xf>Dlc5BZtN$-FMh890s+!Gae1 zXC#&b43Py5o!n#g@L0+r+0aK0aumS_8;nUl_$`K~U0fqr8I-8FAD~>Ybg6%OTqxl#VKCWE1GB;(Y!77(2IxK#R@oRroyMV`zy^^W77;GzQOi#$+TI0d z;v)~DCIM0_fgIqBLMmnWpmILYkGDY!U^op^QCWC=(Q$O) z8~Xr*ki>JaReSssWG$UHIcQ zvwg?$hp!zIwH>{tX9~i^^;(gM$s6t|l;}527`Y^O6uJtGH7l#YzQAR$gTNJLp(JB9 zJ-D$fbV+aXB;4T=bgW^svIhOI=^{o^cI=5-+-sVRQ2-Et2#v^_!al3tY2-#@m=R~J zxml+DiVx$Y4QCxZkmJRIL>~FE3vL8%a$=XR5mVFM?ylg#uI%XXS}ptbr)tBngdT=b z?BYNPv`eZy}U-UILJC+QB$T&KvCA zZxKhrxwsF~{s1`LXbLr)@l$#RXU2n69EuY+&1Rph-K8H_zRZKAR*_I2q?vF4MeO$) zZnFhfNV&Wz#LEj$P=HeS9qigN;-|fc5mbk*Ae7L&DYo;6WN1|J%z6ZtPFN%9RElay zOaY{lq30(S`X)l#23$%_2!bkZXAne`Ktc^q>nCnx);3)HG)YW1gN+zCE^mRvQ%2)J zw7@|%reogYjB>pY{EFMAkjiYh&_K*-ipml;a|PgaY!ys|5|l)&z-1qEbO0ebF&-hl z1U$m>1b#)1>|?G<7|az_d$Af{AW(v5xeJi^VSgHsbru?2P|R!G?cf^aVJ`{J^yLu9 zk6$!FGuICaCXA)kQKX0jThZ}d|2TG;vsj}(-`1a*ET8 z7)k?9J_h$LR-=0Mn|pBOV_nVOpR8@lKKNabRdEf{1ke+@T(uPt07fl%L>kgh)Q1oG zIt0<+!Bzu`W*FB|u3AmFMEHt!g`-XaZeXGpf&^neI7ifqc4ZmHA}J4JzZG#g26;_pjl4p>N=NKknKV)rDMK@0Al{+dBQ*;SYsfH0CA6!KU7(3- zDg$6V)0^0NZVIPB0UY)49C$l^1k+;xAL`ety>Flu!3|Va#LU57rvLpELROPjLWFU! zK|+h8!wItYd7vGMZ%d&&dtnm-ur~IQhON->&YKn8n8zurVw#xm#(bP7#G_cpyFV=Q znqz@2%bS&X+LSP8v7fedvFho)vH-V`B2xV8rBnhDlqHe5I7NIOl#DYTx||*-k$i&BtE?;oaD9Aq}VjPjB zRagc8Sw!#$1uSNXj9I4gj6njBVDuK!v=s@+^$IDSnHUBxVT7<>szF6QHZL=ka%rWVgRoI;|5N_o?C$cyNcm%u>=1p zf#DdZG+2Ry0Uzk<3`CcR=?4^PV1)uqL{UQVMalpV;ZBI`Oci}NhCi$jeAw%|Vd1@o%t2Uy|5DNbd1^-o>J*b)3W;`}D!EXar7+@j zWxoi6oWwvRFChYb3#OHQE(Yy+l_3m>I(J0wqjcHIk)712(%Nfi=Rb_>sKl)-uR;_- Qmy!B_p|~XedF_S&1EPl3eE { + const now = Date.now() + for (const [ip, entry] of windows) { + if (now - entry.start > WINDOW_MS) windows.delete(ip) + } +}, 5 * 60_000).unref() + export function checkLoginRateLimit(ip) { const now = Date.now() const entry = windows.get(ip) diff --git a/server/src/routes/api/admin-profile.js b/server/src/routes/api/admin-profile.js index d69f0c5..748d45c 100644 --- a/server/src/routes/api/admin-profile.js +++ b/server/src/routes/api/admin-profile.js @@ -11,7 +11,6 @@ export async function registerAdminProfileRoutes(fastify) { email: user.email, displayName: user.displayName, avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, } }) @@ -25,7 +24,6 @@ export async function registerAdminProfileRoutes(fastify) { return { avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, } }) @@ -37,17 +35,12 @@ export async function registerAdminProfileRoutes(fastify) { nameRaw === undefined ? undefined : nameRaw === null ? null : nameRaw === '' ? null : String(nameRaw).trim() const avatarRaw = request.body?.avatar const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() - const avatarTypeRaw = request.body?.avatarType - const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() const avatarStyleRaw = request.body?.avatarStyle const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() if (displayName !== undefined && displayName !== null && displayName.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) - if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { - return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) - } if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) { return reply.code(400).send({ error: 'Стиль аватара слишком длинный' }) @@ -57,9 +50,6 @@ export async function registerAdminProfileRoutes(fastify) { if (displayName !== undefined) { data.displayName = displayName && displayName.length ? displayName : null } - if (avatarType !== undefined) { - data.avatarType = avatarType === '' ? null : avatarType - } if (avatar !== undefined) { data.avatar = avatar === '' ? null : avatar } @@ -73,7 +63,6 @@ export async function registerAdminProfileRoutes(fastify) { email: updated.email, displayName: updated.displayName, avatar: updated.avatar, - avatarType: updated.avatarType, avatarStyle: updated.avatarStyle, } }) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index ce870a8..5d44129 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -22,7 +22,6 @@ function mapUserForClient(user) { lastName: user.lastName, gender: user.gender, avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, } @@ -197,17 +196,12 @@ export async function registerAuthRoutes(fastify) { const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() const avatarRaw = request.body?.avatar const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() - const avatarTypeRaw = request.body?.avatarType - const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() const avatarStyleRaw = request.body?.avatarStyle const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() if (displayName !== null && displayName.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) - if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { - return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) - } if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) { return reply.code(400).send({ error: 'Стиль аватара слишком длинный' }) @@ -217,9 +211,6 @@ export async function registerAuthRoutes(fastify) { displayName: displayName && displayName.length ? displayName : null, } - if (avatarType !== undefined) { - data.avatarType = avatarType === '' ? null : avatarType - } if (avatar !== undefined) { data.avatar = avatar === '' ? null : avatar } From 5f180fffafd10072e25cc2a2d69301acecb577a5 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 11:41:40 +0500 Subject: [PATCH 03/23] refactor(server): oauth only email, remove profile requests, support account linking state --- server/src/routes/oauth-social.js | 115 ++++++++++++++---------------- 1 file changed, 53 insertions(+), 62 deletions(-) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index dba2db1..8eeea59 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -17,7 +17,7 @@ async function issueUserJwt(fastify, userId, email) { return fastify.jwt.sign({ sub: userId, email }) } -async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) { +async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) { const existingLink = await prisma.oAuthAccount.findUnique({ where: { provider_providerUserId: { provider, providerUserId } }, include: { user: true }, @@ -34,6 +34,15 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : '' const norm = trimmed ? normalizeEmail(trimmed) : null + + if (linkToUserId) { + if (!norm) return null + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, + }) + return prisma.user.findUnique({ where: { id: linkToUserId } }) + } + let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null if (user) { await prisma.oAuthAccount.create({ @@ -42,16 +51,22 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken return user } - let email = norm || `${provider}_${providerUserId}@oauth.craftshop.local` - let n = 0 - while (await prisma.user.findUnique({ where: { email } })) { - n += 1 - email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local` - } - user = await prisma.user.create({ data: { email } }) + if (!norm) return null + + user = await prisma.user.create({ + data: { + email: norm, + displayName: norm.split('@')[0], + avatar: null, + avatarStyle: 'avataaars', + }, + }) await prisma.oAuthAccount.create({ data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true }, + }) return user } @@ -85,9 +100,10 @@ export async function registerOAuthSocialRoutes(fastify) { return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK')) } + let statePayload = null try { - const state = typeof query.state === 'string' ? query.state : '' - fastify.jwt.verify(state || '') + const raw = typeof query.state === 'string' ? query.state : '' + statePayload = fastify.jwt.verify(raw || '') } catch { return oauthErrorRedirect(reply, 'Недействительный state OAuth') } @@ -114,50 +130,26 @@ export async function registerOAuthSocialRoutes(fastify) { const vkUserId = tokenBody?.user_id const accessTokenVk = tokenBody?.access_token - let emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null - let firstName = null - let lastName = null - let gender = null - let avatar = null - try { - if (accessTokenVk && vkUserId) { - const u = new URL('https://api.vk.com/method/users.get') - u.searchParams.set('access_token', accessTokenVk) - u.searchParams.set('users_ids', String(vkUserId)) - u.searchParams.set('fields', 'photo_200,sex') - u.searchParams.set('v', '5.199') - const profRes = await fetch(u.toString()) - const prof = await profRes.json() - const u0 = prof?.response?.[0] - if (u0) { - firstName = u0.first_name ?? null - lastName = u0.last_name ?? null - avatar = u0.photo_200 ?? null - if (u0.sex === 1) gender = 'female' - else if (u0.sex === 2) gender = 'male' - } - } - } catch { - // ignore profile extras - } + const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null + + if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') + + const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined const user = await findOrCreateUserFromOAuth({ provider: 'vk', providerUserId: String(vkUserId), accessToken: accessTokenVk ?? null, suggestedEmail: emailSuggestion, + linkToUserId, }) - const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() - const updateData = {} - if (displayName && !user.displayName) updateData.displayName = displayName - if (firstName) updateData.firstName = firstName - if (lastName) updateData.lastName = lastName - if (gender) updateData.gender = gender - if (avatar) updateData.avatar = avatar - if (Object.keys(updateData).length > 0) { - await prisma.user.update({ where: { id: user.id }, data: updateData }) + if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') + + if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`) } const token = await issueUserJwt(fastify, user.id, user.email) @@ -176,7 +168,7 @@ export async function registerOAuthSocialRoutes(fastify) { url.searchParams.set('response_type', 'code') url.searchParams.set('client_id', clientId) url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'login:email login:info') + url.searchParams.set('scope', 'login:email') url.searchParams.set('state', state) return reply.redirect(url.toString()) @@ -186,9 +178,10 @@ export async function registerOAuthSocialRoutes(fastify) { const query = request.query ?? {} if (query.error) return oauthErrorRedirect(reply, String(query.error)) + let statePayload = null try { - const state = typeof query.state === 'string' ? query.state : '' - fastify.jwt.verify(state || '') + const raw = typeof query.state === 'string' ? query.state : '' + statePayload = fastify.jwt.verify(raw || '') } catch { return oauthErrorRedirect(reply, 'Недействительный state OAuth') } @@ -233,27 +226,25 @@ export async function registerOAuthSocialRoutes(fastify) { const emailGuess = (Array.isArray(info?.emails) && info.emails[0]) || info?.default_email || - (info?.login ? `${info.login}@yandex.ru` : null) + null + + if (!emailGuess) return oauthErrorRedirect(reply, 'no_email') + + const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined const user = await findOrCreateUserFromOAuth({ provider: 'yandex', providerUserId: yaUserId, accessToken: yaToken, - suggestedEmail: emailGuess || null, + suggestedEmail: emailGuess, + linkToUserId, }) - const updateData = {} - const displayName = - [info.first_name, info.last_name].filter(Boolean).join(' ').trim() || info.display_name || info.real_name - if (displayName && !user.displayName) updateData.displayName = displayName - if (info.first_name) updateData.firstName = info.first_name - if (info.last_name) updateData.lastName = info.last_name - if (info.sex === 'male' || info.sex === 'female') updateData.gender = info.sex - if (info.default_avatar_id && !info.is_avatar_empty) { - updateData.avatar = `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200` - } - if (Object.keys(updateData).length > 0) { - await prisma.user.update({ where: { id: user.id }, data: updateData }) + if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс') + + if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`) } const token = await issueUserJwt(fastify, user.id, user.email) From c9fa05b7bf01d62c43315852e0c8f97a67bdb56e Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 11:45:12 +0500 Subject: [PATCH 04/23] feat(server): add oauth link routes for account binding --- server/src/routes/oauth-social.js | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 8eeea59..9fae41b 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -94,6 +94,34 @@ export async function registerOAuthSocialRoutes(fastify) { return reply.redirect(url.toString()) }) + fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.VK_CLIENT_ID + const clientSecret = process.env.VK_CLIENT_SECRET + if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` + const state = fastify.jwt.sign( + { oauth: 'vk', action: 'link', userId: request.user.sub }, + { expiresIn: '15m' }, + ) + + const url = new URL('https://oauth.vk.com/authorize') + url.searchParams.set('client_id', clientId) + url.searchParams.set('display', 'page') + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'email') + url.searchParams.set('response_type', 'code') + url.searchParams.set('v', '5.199') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) + }) + fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => { const query = request.query ?? {} if (query.error || query.error_description) { @@ -174,6 +202,31 @@ export async function registerOAuthSocialRoutes(fastify) { return reply.redirect(url.toString()) }) + fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.YANDEX_CLIENT_ID + if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` + const state = fastify.jwt.sign( + { oauth: 'yandex', action: 'link', userId: request.user.sub }, + { expiresIn: '15m' }, + ) + + const url = new URL('https://oauth.yandex.ru/authorize') + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'login:email') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) + }) + fastify.get('/api/auth/oauth/yandex/callback', async (request, reply) => { const query = request.query ?? {} if (query.error) return oauthErrorRedirect(reply, String(query.error)) From abb14a49e0982f8492bad87d222c90508bcfe327 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 11:47:46 +0500 Subject: [PATCH 05/23] feat(server): add auth-methods, set-password, unlink-oauth endpoints --- server/src/routes/auth.js | 69 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 5d44129..4b2a2e5 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -145,6 +145,75 @@ export async function registerAuthRoutes(fastify) { return { user: mapUserForClient(user) } }) + fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { oauthAccounts: { select: { provider: true } } }, + }) + if (!user) return { methods: [] } + + const providers = user.oauthAccounts.map((a) => a.provider) + return { + methods: [ + { type: 'password', active: Boolean(user.passwordHash) }, + { type: 'vk', active: providers.includes('vk') }, + { type: 'yandex', active: providers.includes('yandex') }, + ], + } + }) + + fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' }) + + const password = String(request.body?.password || '') + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(password) + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) + + return { ok: true } + }) + + fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const provider = request.params?.provider + + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' }) + } + if (provider !== 'vk' && provider !== 'yandex') { + return reply.code(400).send({ error: 'Неизвестный провайдер' }) + } + + const oauth = await prisma.oAuthAccount.findFirst({ + where: { userId, provider }, + }) + if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' }) + + const remainingOAuth = await prisma.oAuthAccount.count({ + where: { userId, provider: { not: provider } }, + }) + const currentUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordHash: true }, + }) + if (!currentUser?.passwordHash && remainingOAuth === 0) { + return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' }) + } + + await prisma.oAuthAccount.delete({ where: { id: oauth.id } }) + return { ok: true } + }) + fastify.post('/api/me/change-email/request-code', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const newEmail = normalizeEmail(request.body?.newEmail) From 6bedf0b28a192b060322214cad93c828ccecdd7d Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 11:57:11 +0500 Subject: [PATCH 06/23] test(server): add password auth and account methods tests --- .../src/routes/__tests__/auth-methods.test.js | 170 ++++++++++++++++++ .../routes/__tests__/auth-password.test.js | 134 ++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 server/src/routes/__tests__/auth-methods.test.js create mode 100644 server/src/routes/__tests__/auth-password.test.js diff --git a/server/src/routes/__tests__/auth-methods.test.js b/server/src/routes/__tests__/auth-methods.test.js new file mode 100644 index 0000000..c0fa825 --- /dev/null +++ b/server/src/routes/__tests__/auth-methods.test.js @@ -0,0 +1,170 @@ +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest' +import { prisma } from '../../lib/prisma.js' +import { registerAuthRoutes } from '../auth.js' + +const JWT_SECRET = 'test-secret' + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(jwt, { secret: JWT_SECRET }) + app.decorate('authenticate', async function (request, reply) { + try { await request.jwtVerify() } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', { emit: () => {} }) + await registerAuthRoutes(app) + await app.ready() + return app +} + +function signToken(app, userId, email) { + return app.jwt.sign({ sub: userId, email }) +} + +async function createUser(email) { + const user = await prisma.user.create({ + data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' }, + }) + await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } }) + return user +} + +describe('GET /api/me/auth-methods', () => { + let app, user, token + const email = `test-methods-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { + await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } }) + await prisma.user.deleteMany({ where: { email } }) + await app.close() + }) + + beforeEach(async () => { + await prisma.oAuthAccount.deleteMany({ where: { user: { email } } }) + await prisma.notificationPreference.deleteMany({ where: { user: { email } } }) + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('returns methods for user without any method', async () => { + const res = await app.inject({ + method: 'GET', url: '/api/me/auth-methods', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) + expect(body.methods.find((m) => m.type === 'password').active).toBe(false) + expect(body.methods.find((m) => m.type === 'vk').active).toBe(false) + expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false) + }) + + it('returns password as active after setting it', async () => { + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) + const res = await app.inject({ + method: 'GET', url: '/api/me/auth-methods', + headers: { authorization: `Bearer ${token}` }, + }) + expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true) + }) +}) + +describe('POST /api/me/password', () => { + let app, user, token + const email = `test-set-pw-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { + await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } }) + await prisma.user.deleteMany({ where: { email } }) + await app.close() + }) + + beforeEach(async () => { + await prisma.notificationPreference.deleteMany({ where: { user: { email } } }) + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('sets password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/me/password', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(200) + + const u = await prisma.user.findUnique({ where: { id: user.id } }) + expect(u.passwordHash).toBeTruthy() + }) + + it('rejects if password already set', async () => { + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } }) + const res = await app.inject({ + method: 'POST', url: '/api/me/password', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(409) + }) +}) + +describe('DELETE /api/me/oauth/:provider', () => { + let app, user, token + const email = `test-unlink-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { + await prisma.oAuthAccount.deleteMany({ where: { user: { email } } }) + await prisma.notificationPreference.deleteMany({ where: { user: { email } } }) + await prisma.user.deleteMany({ where: { email } }) + await app.close() + }) + + beforeEach(async () => { + await prisma.oAuthAccount.deleteMany({ where: { user: { email } } }) + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('returns 404 for non-linked provider', async () => { + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('unlinks a provider', async () => { + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) + await prisma.oAuthAccount.create({ + data: { provider: 'vk', providerUserId: '123', userId: user.id }, + }) + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + + const count = await prisma.oAuthAccount.count({ where: { userId: user.id } }) + expect(count).toBe(0) + }) + + it('rejects removing last method without password', async () => { + await prisma.oAuthAccount.create({ + data: { provider: 'vk', providerUserId: '123', userId: user.id }, + }) + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('последний метод') + }) +}) diff --git a/server/src/routes/__tests__/auth-password.test.js b/server/src/routes/__tests__/auth-password.test.js new file mode 100644 index 0000000..c281c90 --- /dev/null +++ b/server/src/routes/__tests__/auth-password.test.js @@ -0,0 +1,134 @@ +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { prisma } from '../../lib/prisma.js' +import { registerAuthRoutes } from '../auth.js' + +const JWT_SECRET = 'test-secret' +const TEST_EMAIL = `test-reg-${Date.now()}@example.com` +const LOGIN_EMAIL = `test-login-${Date.now()}@example.com` + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(jwt, { secret: JWT_SECRET }) + app.decorate('authenticate', async function (request, reply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', { emit: () => {} }) + await registerAuthRoutes(app) + await app.ready() + return app +} + +describe('POST /api/auth/register', () => { + let app + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + afterEach(async () => { + await prisma.authCode.deleteMany({ where: { email: TEST_EMAIL } }) + await prisma.notificationPreference.deleteMany({ where: { user: { email: TEST_EMAIL } } }) + await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }) + }) + + it('registers a new user with password', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(201) + const body = JSON.parse(res.body) + expect(body.token).toBeTruthy() + expect(body.user.email).toBe(TEST_EMAIL) + }) + + it('rejects duplicate email', async () => { + await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(409) + }) + + it('rejects weak password — too short', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Ab1!' }, + }) + expect(res.statusCode).toBe(400) + const body = JSON.parse(res.body) + expect(body.error).toContain('не менее 8') + }) + + it('rejects weak password — no digit', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Abcdefgh!' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('цифру') + }) + + it('rejects weak password — no special char', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Abcdefg1' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('спецсимвол') + }) +}) + +describe('POST /api/auth/login', () => { + let app + beforeAll(async () => { + app = await buildApp() + await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: LOGIN_EMAIL, password: 'Test123!@' }, + }) + }) + afterAll(async () => { + await prisma.authCode.deleteMany({ where: { email: LOGIN_EMAIL } }) + await prisma.notificationPreference.deleteMany({ where: { user: { email: LOGIN_EMAIL } } }) + await prisma.oAuthAccount.deleteMany({ where: { user: { email: LOGIN_EMAIL } } }) + await prisma.user.deleteMany({ where: { email: LOGIN_EMAIL } }) + await app.close() + }) + + it('logs in with correct password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: LOGIN_EMAIL, password: 'Test123!@' }, + headers: { 'x-forwarded-for': '1.1.1.1' }, + }) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body).token).toBeTruthy() + }) + + it('rejects wrong password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: LOGIN_EMAIL, password: 'Wrong!!1!' }, + headers: { 'x-forwarded-for': '2.2.2.2' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('rejects non-existent email', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: 'nobody@nowhere.test', password: 'Test123!@' }, + headers: { 'x-forwarded-for': '3.3.3.3' }, + }) + expect(res.statusCode).toBe(401) + }) +}) From be65f2330e9148b035d639d95b40fd23e9b53030 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 12:08:41 +0500 Subject: [PATCH 07/23] refactor(client): remove avatarType, add auth effects, simplify UserAvatar --- .../src/features/order-chat/ui/OrderChat.tsx | 2 - .../order-detail/ui/OrderDetailContent.tsx | 2 - .../product-review/ui/ProductReviewsList.tsx | 1 - .../features/user/user-menu/ui/UserMenu.tsx | 1 - .../admin-settings/ui/AdminSettingsPage.tsx | 29 +------------- .../pages/admin-users/ui/AdminUsersPage.tsx | 1 - .../src/pages/me/ui/sections/MessagesPage.tsx | 2 - .../src/pages/me/ui/sections/SettingsPage.tsx | 25 +----------- client/src/shared/model/auth.ts | 36 +++++++++++++++++- client/src/shared/ui/UserAvatar.tsx | 5 +-- .../widgets/reviews-block/ui/ReviewsBlock.tsx | 1 - server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 12 files changed, 40 insertions(+), 65 deletions(-) diff --git a/client/src/features/order-chat/ui/OrderChat.tsx b/client/src/features/order-chat/ui/OrderChat.tsx index 27d1c8a..65eac11 100644 --- a/client/src/features/order-chat/ui/OrderChat.tsx +++ b/client/src/features/order-chat/ui/OrderChat.tsx @@ -56,7 +56,6 @@ export function OrderChat({ messages, isPending, onSend }: Props) { @@ -64,7 +63,6 @@ export function OrderChat({ messages, isPending, onSend }: Props) { diff --git a/client/src/features/order-detail/ui/OrderDetailContent.tsx b/client/src/features/order-detail/ui/OrderDetailContent.tsx index 721c478..36f5b0c 100644 --- a/client/src/features/order-detail/ui/OrderDetailContent.tsx +++ b/client/src/features/order-detail/ui/OrderDetailContent.tsx @@ -175,7 +175,6 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta @@ -184,7 +183,6 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx index ed03e3d..a7f05e4 100644 --- a/client/src/features/product-review/ui/ProductReviewsList.tsx +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -22,7 +22,6 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index ab661c3..44b7828 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -46,7 +46,6 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx index 41adb80..b4da721 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -56,9 +56,7 @@ export function AdminSettingsPage() { mode: 'onChange', }) - const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') - const useOAuth = user?.avatarType === 'oauth' - const useGenerated = user?.avatarType === 'generated' + const hasAvatar = Boolean(user?.avatar) const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) const [previewSrc, setPreviewSrc] = useState(null) @@ -70,14 +68,12 @@ export function AdminSettingsPage() { mutationFn: (params: { displayName: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null }) => apiClient.patch('admin/profile', params), onSuccess: (_data, variables) => { const p: UpdateProfileParams = { displayName: variables.displayName ?? null } if (variables.avatar !== undefined) { p.avatar = variables.avatar - p.avatarType = variables.avatarType ?? null p.avatarStyle = variables.avatarStyle ?? null } updateProfileFx(p) @@ -144,7 +140,6 @@ export function AdminSettingsPage() { - {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} + {hasUnsavedPreview ? 'Предпросмотр' : hasAvatar ? 'Сохранён' : 'Авто'} {hasUnsavedPreview && ( @@ -161,7 +156,6 @@ export function AdminSettingsPage() { )} - - {hasOAuthAvatar && !hasUnsavedPreview && ( - - )} diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index 6e5d85b..cabc230 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -195,7 +195,6 @@ export function AdminUsersPage() { diff --git a/client/src/pages/me/ui/sections/MessagesPage.tsx b/client/src/pages/me/ui/sections/MessagesPage.tsx index 220753d..f225f78 100644 --- a/client/src/pages/me/ui/sections/MessagesPage.tsx +++ b/client/src/pages/me/ui/sections/MessagesPage.tsx @@ -181,7 +181,6 @@ export function MessagesPage() { @@ -189,7 +188,6 @@ export function MessagesPage() { diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 2b0e403..54cb98e 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -56,9 +56,7 @@ export function SettingsPage() { const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) - const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') - const useOAuth = user?.avatarType === 'oauth' - const useGenerated = user?.avatarType === 'generated' + const hasAvatar = Boolean(user?.avatar) const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) const [previewSrc, setPreviewSrc] = useState(null) @@ -128,7 +126,6 @@ export function SettingsPage() { - {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} + {hasUnsavedPreview ? 'Предпросмотр' : hasAvatar ? 'Сохранён' : 'Авто'} {hasUnsavedPreview && ( @@ -145,7 +142,6 @@ export function SettingsPage() { )} - - {hasOAuthAvatar && !hasUnsavedPreview && ( - - )} diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 6761582..0e4e96d 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -11,11 +11,15 @@ export type AuthUser = { lastName?: string | null gender?: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null isAdmin?: boolean } +export type AuthMethod = { + type: 'password' | 'vk' | 'yandex' + active: boolean +} + export const tokenSet = createEvent() export const logout = createEvent() @@ -72,7 +76,6 @@ export const verifyEmailChangeFx = createEffect(async (params: { newEmail: strin export type UpdateProfileParams = { displayName: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null } @@ -81,6 +84,35 @@ export const updateProfileFx = createEffect(async (params: UpdateProfileParams) return data.user }) +// ----- Auth effects ----- + +export const loginFx = createEffect(async (params: { email: string; password: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/login', params) + tokenSet(data.token) + return data.user +}) + +export const registerFx = createEffect( + async (params: { email: string; password: string; displayName?: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) + tokenSet(data.token) + return data.user + }, +) + +export const fetchAuthMethodsFx = createEffect(async () => { + const { data } = await apiClient.get<{ methods: AuthMethod[] }>('me/auth-methods') + return data.methods +}) + +export const setPasswordFx = createEffect(async (password: string) => { + await apiClient.post('me/password', { password }) +}) + +export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => { + await apiClient.delete(`me/oauth/${provider}`) +}) + // ----- Error stores ----- export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error diff --git a/client/src/shared/ui/UserAvatar.tsx b/client/src/shared/ui/UserAvatar.tsx index bc3eecf..7dd1fbe 100644 --- a/client/src/shared/ui/UserAvatar.tsx +++ b/client/src/shared/ui/UserAvatar.tsx @@ -7,20 +7,19 @@ import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' type UserAvatarProps = { userId: string avatarUrl?: string | null - avatarType?: string | null avatarStyle?: string | null size?: number sx?: SxProps } -export function UserAvatar({ userId, avatarUrl, avatarType, avatarStyle, size = 40, sx }: UserAvatarProps) { +export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { const generatedSrc = useMemo(() => { const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) const avatar = createAvatar(styleDef.style, { seed: userId }) return avatar.toDataUri() }, [userId, avatarStyle]) - const src = avatarType && avatarUrl ? avatarUrl : generatedSrc + const src = avatarUrl || generatedSrc return ( diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index 4e5cb76..2a2f622 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -104,7 +104,6 @@ export function ReviewsBlock() { diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index e8e8ce5498e9b3fb8a44fca779fbea1701b91a6d..037559b852858d29f809986b677af85cf89195b2 100644 GIT binary patch delta 1478 zcma)6&rj1(9BzuCT-&|MCf+1kD@0>!oy?XyNi=U3*#f`LO?J2x#afYDd8E1j@iQIs=}N_$l@(x zAKi(y*VhvlZktE94I;`6;B>>_Wt+06P&O)lUsBU$U@ktkg*rq+#|ga2%Rntw2_ zn|EPjZZ?-T28kwdNSmhFOCiNCC4lJ2HHFni42 vfjjh^*i}{pluAW~R^_o&1v_@^cyb6SRVaC=rL=Sa94F4xv6GtC_R>rY zT_{twwgU?SiAP(Pk%5hXIx&<5b%f5W;kT1WMKR(ZE|TxlcX!`+m&VR;V`q5t0_^+! zd^8H;yT>n|A>Z+%-J!@UJU)TMp{kOTg<@rjWLb`;NSdR9BooncE82BYk>RUXkQA2B zX;Z3T^e}9Q4wFtfPS zYn_$^I=jLG7Q$qy5>a9~nV{F;QH1Z+VCo<#gfv;B^PI?rGWyd)$U20vaY)@AA|$-2 zw&7?ZI+M;UB#N;@MUBng4kz?Gw-)Xz$)&r+B3oWuS_n=p7M9JqB{0V9+Uup|8!^%{en}jO`TS~pWpJMM`PE3>0A($mTW_w zFnU=#X9Z^S4giLw?}_SEv<1O@3Vx%0|_p5$R%GUMU+{wU)8na9syzEB@p7 zVJF6+Gdzzu&+F|D$>^XX(e5Z4uI3p*F?xx<-EsOG*l>O}tOnZRBks+d7Nej4T+jK} z_%c{R3ST$jvphEz2n6D`O-_ALH1nV_A_Y?5tMj@kH?P6@nou7}jU??M2&uAam}RDD vm_=fK4&YLMF-Bdl@xRw@>(Jsm(K Date: Fri, 22 May 2026 12:11:36 +0500 Subject: [PATCH 08/23] feat(client): auth page with 3 tabs (password/code/oauth) --- client/src/pages/auth/ui/AuthPage.tsx | 173 ++++++++++++++++++++------ 1 file changed, 135 insertions(+), 38 deletions(-) diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index ea35191..3f7e3b4 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -2,8 +2,9 @@ import { useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' -import Divider from '@mui/material/Divider' import Stack from '@mui/material/Stack' +import Tab from '@mui/material/Tab' +import Tabs from '@mui/material/Tabs' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' @@ -21,7 +22,6 @@ type AuthResponse = { email: string displayName?: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null } } @@ -38,19 +38,28 @@ function getApiErrorMessage(err: unknown): string | null { export function AuthPage() { const [message, setMessage] = useState(null) const [oauthError, setOauthError] = useState(null) + const [tab, setTab] = useState(0) + const [isRegister, setIsRegister] = useState(false) const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const user = useUnit($user) + const { register, watch } = useForm<{ email: string + password: string + passwordConfirm: string + displayName: string code: string }>({ - defaultValues: { email: '', code: '' }, + defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' }, mode: 'onChange', }) const email = watch('email') + const password = watch('password') + const passwordConfirm = watch('passwordConfirm') const code = watch('code') + useEffect(() => { if (user) navigate('/', { replace: true }) }, [navigate, user]) @@ -62,6 +71,26 @@ export function AuthPage() { setSearchParams({}, { replace: true }) }, [searchParams, setSearchParams]) + const loginMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/login', { email, password }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const registerMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/register', { + email, + password, + displayName: watch('displayName') || undefined, + }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + const requestCode = useMutation({ mutationFn: async () => { await apiClient.post('auth/request-code', { email }) @@ -73,12 +102,18 @@ export function AuthPage() { mutationFn: async () => { const { data } = await apiClient.post('auth/verify-code', { email, code }) tokenSet(data.token) - setMessage(`Вход выполнен: ${data.user.email}`) navigate('/', { replace: true }) }, }) - const errMsg = getApiErrorMessage(requestCode.error || verifyCode.error) + const errMsg = + getApiErrorMessage(loginMutation.error) || + getApiErrorMessage(registerMutation.error) || + getApiErrorMessage(requestCode.error) || + getApiErrorMessage(verifyCode.error) + + const passwordError = + isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null return ( @@ -86,45 +121,107 @@ export function AuthPage() { Вход / регистрация - {message && ( - - {message} - - )} + {message && {message}} {oauthError && ( - setOauthError(null)}> - {oauthError} - - )} - {errMsg && ( - - {errMsg} - + setOauthError(null)}>{oauthError} )} + {errMsg && {errMsg}} - - Email + код - - - - - + setTab(v)} sx={{ mb: 3 }}> + + + + + + {tab === 0 && ( + + + + + + + + + {isRegister && ( + + )} + + + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} - + )} - - или + {tab === 1 && ( + + + + + + + + + )} - - + {tab === 2 && ( + + + + )} ) } From 6d23aafcc167baa89faf9c100412acfce8542c86 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 12:16:58 +0500 Subject: [PATCH 09/23] feat(client): add auth methods section to settings page --- .../src/features/order-chat/ui/OrderChat.tsx | 7 +- .../src/pages/me/ui/sections/SettingsPage.tsx | 135 +++++++++++++++++- 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/client/src/features/order-chat/ui/OrderChat.tsx b/client/src/features/order-chat/ui/OrderChat.tsx index 65eac11..ad07c43 100644 --- a/client/src/features/order-chat/ui/OrderChat.tsx +++ b/client/src/features/order-chat/ui/OrderChat.tsx @@ -53,12 +53,7 @@ export function OrderChat({ messages, isPending, onSend }: Props) { const isAdminMsg = m.authorType === 'admin' const adminAv = adminAvatarQuery.data const avatarNode = isAdminMsg ? ( - + ) : currentUser ? ( ([]) + const [showSetPassword, setShowSetPassword] = useState(false) + const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ + defaultValues: { password: '', passwordConfirm: '' }, + }) + + useEffect(() => { + fetchAuthMethodsFx().then(setAuthMethods).catch(() => { + setAuthMethods([]) + }) + }, []) + + const setPasswordMutation = useMutation({ + mutationFn: async (pw: string) => { + await setPasswordFx(pw) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + setShowSetPassword(false) + }, + onError: () => {}, + }) + + const unlinkMutation = useMutation({ + mutationFn: async (provider: 'vk' | 'yandex') => { + await unlinkOAuthFx(provider) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + }, + onError: () => {}, + }) + + const linkedCount = useCallback(() => { + return authMethods.filter((m) => m.active).length + }, [authMethods]) + + const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } + if (!user) { return Нужно войти. Перейдите на страницу «Вход». } @@ -134,7 +177,7 @@ export function SettingsPage() { }} /> - {hasUnsavedPreview ? 'Предпросмотр' : hasAvatar ? 'Сохранён' : 'Авто'} + {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} {hasUnsavedPreview && ( @@ -201,6 +244,94 @@ export function SettingsPage() { )} + {!user.isAdmin && ( + <> + + + + Методы входа + + + {authMethods.map((m) => ( + + {METHOD_LABELS[m.type] || m.type} + + {m.active && m.type !== 'password' && ( + + )} + {!m.active && m.type === 'password' && ( + + )} + {!m.active && m.type !== 'password' && ( + + )} + + ))} + + + {showSetPassword && ( + + + + + + + + + )} + + + )} + From 39d6a1604ccd9b598063108f616820cd256038b5 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 12:18:45 +0500 Subject: [PATCH 10/23] fix(client): remove avatarType and OAuth avatar from admin settings --- client/src/pages/admin-settings/ui/AdminSettingsPage.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx index b4da721..dd765fd 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -56,8 +56,6 @@ export function AdminSettingsPage() { mode: 'onChange', }) - const hasAvatar = Boolean(user?.avatar) - const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) const [previewSrc, setPreviewSrc] = useState(null) const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) @@ -148,7 +146,7 @@ export function AdminSettingsPage() { }} /> - {hasUnsavedPreview ? 'Предпросмотр' : hasAvatar ? 'Сохранён' : 'Авто'} + {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} {hasUnsavedPreview && ( From 5651403d2e93603b96ef8bef6a10c78560ffc508 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 12:21:50 +0500 Subject: [PATCH 11/23] test(client): add auth page tab tests --- .../pages/auth/__tests__/AuthPage.test.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 client/src/pages/auth/__tests__/AuthPage.test.tsx diff --git a/client/src/pages/auth/__tests__/AuthPage.test.tsx b/client/src/pages/auth/__tests__/AuthPage.test.tsx new file mode 100644 index 0000000..e068d9c --- /dev/null +++ b/client/src/pages/auth/__tests__/AuthPage.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { AuthPage } from '../ui/AuthPage' + +vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => null } +}) + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('AuthPage', () => { + it('renders three tabs', () => { + renderPage() + expect(screen.getByRole('tab', { name: 'Пароль' })).toBeTruthy() + expect(screen.getByRole('tab', { name: 'Код' })).toBeTruthy() + expect(screen.getByRole('tab', { name: 'Другой способ' })).toBeTruthy() + }) + + it('shows login form by default on tab 0', () => { + renderPage() + expect(screen.getByText('Вход')).toBeTruthy() + expect(screen.getByText('Регистрация')).toBeTruthy() + const buttons = screen.getAllByRole('button') + const loginBtn = buttons.find((b) => b.textContent === 'Войти') + expect(loginBtn).toBeTruthy() + }) + + it('switches to register form', () => { + renderPage() + fireEvent.click(screen.getByText('Регистрация')) + expect(screen.getByText('Зарегистрироваться')).toBeTruthy() + }) + + it('switches to code tab', () => { + renderPage() + fireEvent.click(screen.getByText('Код')) + expect(screen.getByText('Отправить код')).toBeTruthy() + }) + + it('switches to OAuth tab', () => { + renderPage() + fireEvent.click(screen.getByText('Другой способ')) + expect(screen.getByText('Войти через VK ID')).toBeTruthy() + expect(screen.getByText('Войти через Яндекс ID')).toBeTruthy() + }) +}) From b2ccc2a25681f1f5a0db4bebf6d957cacc5c1ab0 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 12:27:20 +0500 Subject: [PATCH 12/23] chore: fix lint issues, remove unused hasAvatar --- .../features/user/user-menu/ui/UserMenu.tsx | 7 +-- .../admin-settings/ui/AdminSettingsPage.tsx | 7 +-- .../pages/admin-users/ui/AdminUsersPage.tsx | 7 +-- .../pages/auth/__tests__/AuthPage.test.tsx | 2 +- client/src/pages/auth/ui/AuthPage.tsx | 27 ++++++---- .../src/pages/me/ui/sections/SettingsPage.tsx | 19 +++---- client/src/shared/model/auth.ts | 12 ++--- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes .../src/routes/__tests__/auth-methods.test.js | 39 ++++++++++----- .../routes/__tests__/auth-password.test.js | 37 +++++++++----- server/src/routes/api.js | 2 +- server/src/routes/api/admin-orders.js | 4 +- server/src/routes/api/public-reviews.js | 4 +- server/src/routes/oauth-social.js | 47 ++++++++---------- 14 files changed, 113 insertions(+), 101 deletions(-) diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index 44b7828..02ea396 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -43,12 +43,7 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > {user ? ( - + ) : ( )} diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx index dd765fd..5bc98fc 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -63,11 +63,8 @@ export function AdminSettingsPage() { const hasUnsavedPreview = previewSrc !== null const profileSaveMut = useMutation({ - mutationFn: (params: { - displayName: string | null - avatar?: string | null - avatarStyle?: string | null - }) => apiClient.patch('admin/profile', params), + mutationFn: (params: { displayName: string | null; avatar?: string | null; avatarStyle?: string | null }) => + apiClient.patch('admin/profile', params), onSuccess: (_data, variables) => { const p: UpdateProfileParams = { displayName: variables.displayName ?? null } if (variables.avatar !== undefined) { diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index cabc230..4c2a506 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -192,12 +192,7 @@ export function AdminUsersPage() { users.map((u) => ( - + {u.email} {u.displayName ?? '—'} diff --git a/client/src/pages/auth/__tests__/AuthPage.test.tsx b/client/src/pages/auth/__tests__/AuthPage.test.tsx index e068d9c..05e2f32 100644 --- a/client/src/pages/auth/__tests__/AuthPage.test.tsx +++ b/client/src/pages/auth/__tests__/AuthPage.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, fireEvent } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { describe, expect, it, vi } from 'vitest' import { AuthPage } from '../ui/AuthPage' diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 3f7e3b4..99182b7 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -112,8 +112,7 @@ export function AuthPage() { getApiErrorMessage(requestCode.error) || getApiErrorMessage(verifyCode.error) - const passwordError = - isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null return ( @@ -121,11 +120,21 @@ export function AuthPage() { Вход / регистрация - {message && {message}} - {oauthError && ( - setOauthError(null)}>{oauthError} + {message && ( + + {message} + + )} + {oauthError && ( + setOauthError(null)}> + {oauthError} + + )} + {errMsg && ( + + {errMsg} + )} - {errMsg && {errMsg}} setTab(v)} sx={{ mb: 3 }}> @@ -198,11 +207,7 @@ export function AuthPage() { - diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 95522a9..9c88dc1 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -12,9 +12,9 @@ import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { createAvatar } from '@dicebear/core' +import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' -import { useMutation } from '@tanstack/react-query' import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { $requestEmailChangeCodeError, @@ -62,8 +62,6 @@ export function SettingsPage() { const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) - const hasAvatar = Boolean(user?.avatar) - const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) const [previewSrc, setPreviewSrc] = useState(null) const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) @@ -77,9 +75,11 @@ export function SettingsPage() { }) useEffect(() => { - fetchAuthMethodsFx().then(setAuthMethods).catch(() => { - setAuthMethods([]) - }) + fetchAuthMethodsFx() + .then(setAuthMethods) + .catch(() => { + setAuthMethods([]) + }) }, []) const setPasswordMutation = useMutation({ @@ -277,12 +277,7 @@ export function SettingsPage() { )} {!m.active && m.type !== 'password' && ( - )} diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 0e4e96d..36cf4d6 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -92,13 +92,11 @@ export const loginFx = createEffect(async (params: { email: string; password: st return data.user }) -export const registerFx = createEffect( - async (params: { email: string; password: string; displayName?: string }) => { - const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) - tokenSet(data.token) - return data.user - }, -) +export const registerFx = createEffect(async (params: { email: string; password: string; displayName?: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) + tokenSet(data.token) + return data.user +}) export const fetchAuthMethodsFx = createEffect(async () => { const { data } = await apiClient.get<{ methods: AuthMethod[] }>('me/auth-methods') diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 037559b852858d29f809986b677af85cf89195b2..378bf4ecb0101a90115a70987425334acb3c7c17 100644 GIT binary patch delta 1696 zcmaJ>O-$Qn7`9`(Yot*JRUo0km^$EsO6`39Nf?s@1)+*I{IqBwA*kaRJF%Vrgd{tJ zUMB4X(+(Ua4Q^9asZ|S&s$IqoJ8aS<&O7$n(+<7tvVE_eAZKR#4zFds&-c8~_q^Zt z?w<_qp9~&;M4I0W-zCkLkDneeTkID5;b#i{3u+zB|hZo{evmnW~%0hDfUUG3~Wp?wyLh?}uvqFXo)GzE0r~QfV*rS~-`hzv# zjO}4g%P1FBK{IpNQr@CQD241)6thrKwd_W;SgjkTkqcqBM!MMw&T6n1Wh;5Us!3m6 zknM}m&vz8e92apm?`9p7ZwjbVv-l_iIiLOI#STwU!x{CC9k`OC9iiUcfmCo)J3(e^V0=utTmHb6r0%tk?7+8Br#~BgD}4>(otxU>KzX)ewfp zfKsa!Q;o7?yOg&MXS_yfUCKa<&gkD+bfu`!Fr{=`8{}_C=s!j$?6lKRap_$iRjo!k zR@u$g1ni<9!bzq}UhLR)1+<-@!yJ`mU*jjR%^qc2bbkwrk@9dH=xa@G7Vu54b@T@t z?sn?Li@5hoO4wZt)7cT{F_i;twoAGdFPl;)5p0l4^8T9lY43xwo(uUHE@Y6;e<6dy zdoConuY;DF)`;z<%BemqnTQa?jRW=>`y87vc%zkJK2*LKfW+v~BRmb%np#m+wNy6MvZ<%tm)J&#jd99$KZMt9Z|gSn!Xlpc=1Bi#P#W#K9YArcpeq$-FD*e`g`k zO`mxYbLrUk9sC{jSl4hTmZ1h-mt}%e?#fZ>?*j18Y<(wlWH rxLQHiVfJr!pDvuPX9P38NfKM(!^fWZ>Z delta 1433 zcmah}PfXKL7;pDlrBNuvNJ0cLK!^ze-r99r52j-R%7K9h1QH{5W9(noZf!T%JPa-* zUSyt#7v(@gj3)vvcrqS1c+ha>;6aX@J$Tah+6_jE`VL>ye82boe((3*_pNTVu5PuK zZ;{Na_WLBW`|RBYz02;h$9paCIa}%lk4Uo%jFGAqP0I9-fB+bc=kzj2h-Z zL`#FaV1~p${fZV#sAnjT=;L4@x+14DN)!WK1VQx1qGo?aE}WqR$Z_s^6lg>+qM0D} z8nzDJ22yb&mxU1Ws&8pA?)4YdY&cH72^Gq-u5L=AnMgVlL!f*dfxH-`7sGnIxGEIAKrrS|&fTn+s2;xzJ^rDwf zY##guz2nFOi+8rl>$EjXHcNd>2hrBh0B#TjO-nU%@nQV9WCrrOVFQ@QbIK|0(J=B9Or>vWhJ3i^*b*dGiG;DHyB(3P=?Sb3t+N9=2gJaCXOVSW)sDX^M-+31K4f>;oxPU8 oYkN*iT|Rq22aw2%w$! { let app, user, token const email = `test-methods-${Date.now()}@example.com` - beforeAll(async () => { app = await buildApp() }) + beforeAll(async () => { + app = await buildApp() + }) afterAll(async () => { await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } }) await prisma.user.deleteMany({ where: { email } }) @@ -53,7 +57,8 @@ describe('GET /api/me/auth-methods', () => { it('returns methods for user without any method', async () => { const res = await app.inject({ - method: 'GET', url: '/api/me/auth-methods', + method: 'GET', + url: '/api/me/auth-methods', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(200) @@ -66,7 +71,8 @@ describe('GET /api/me/auth-methods', () => { it('returns password as active after setting it', async () => { await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) const res = await app.inject({ - method: 'GET', url: '/api/me/auth-methods', + method: 'GET', + url: '/api/me/auth-methods', headers: { authorization: `Bearer ${token}` }, }) expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true) @@ -77,7 +83,9 @@ describe('POST /api/me/password', () => { let app, user, token const email = `test-set-pw-${Date.now()}@example.com` - beforeAll(async () => { app = await buildApp() }) + beforeAll(async () => { + app = await buildApp() + }) afterAll(async () => { await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } }) await prisma.user.deleteMany({ where: { email } }) @@ -93,7 +101,8 @@ describe('POST /api/me/password', () => { it('sets password', async () => { const res = await app.inject({ - method: 'POST', url: '/api/me/password', + method: 'POST', + url: '/api/me/password', headers: { authorization: `Bearer ${token}` }, payload: { password: 'Test123!@' }, }) @@ -106,7 +115,8 @@ describe('POST /api/me/password', () => { it('rejects if password already set', async () => { await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } }) const res = await app.inject({ - method: 'POST', url: '/api/me/password', + method: 'POST', + url: '/api/me/password', headers: { authorization: `Bearer ${token}` }, payload: { password: 'Test123!@' }, }) @@ -118,7 +128,9 @@ describe('DELETE /api/me/oauth/:provider', () => { let app, user, token const email = `test-unlink-${Date.now()}@example.com` - beforeAll(async () => { app = await buildApp() }) + beforeAll(async () => { + app = await buildApp() + }) afterAll(async () => { await prisma.oAuthAccount.deleteMany({ where: { user: { email } } }) await prisma.notificationPreference.deleteMany({ where: { user: { email } } }) @@ -135,7 +147,8 @@ describe('DELETE /api/me/oauth/:provider', () => { it('returns 404 for non-linked provider', async () => { const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', + method: 'DELETE', + url: '/api/me/oauth/vk', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(404) @@ -147,7 +160,8 @@ describe('DELETE /api/me/oauth/:provider', () => { data: { provider: 'vk', providerUserId: '123', userId: user.id }, }) const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', + method: 'DELETE', + url: '/api/me/oauth/vk', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(200) @@ -161,7 +175,8 @@ describe('DELETE /api/me/oauth/:provider', () => { data: { provider: 'vk', providerUserId: '123', userId: user.id }, }) const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', + method: 'DELETE', + url: '/api/me/oauth/vk', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(400) diff --git a/server/src/routes/__tests__/auth-password.test.js b/server/src/routes/__tests__/auth-password.test.js index c281c90..db6cbf9 100644 --- a/server/src/routes/__tests__/auth-password.test.js +++ b/server/src/routes/__tests__/auth-password.test.js @@ -1,5 +1,5 @@ -import Fastify from 'fastify' import jwt from '@fastify/jwt' +import Fastify from 'fastify' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { prisma } from '../../lib/prisma.js' import { registerAuthRoutes } from '../auth.js' @@ -26,8 +26,12 @@ async function buildApp() { describe('POST /api/auth/register', () => { let app - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) + beforeAll(async () => { + app = await buildApp() + }) + afterAll(async () => { + await app.close() + }) afterEach(async () => { await prisma.authCode.deleteMany({ where: { email: TEST_EMAIL } }) await prisma.notificationPreference.deleteMany({ where: { user: { email: TEST_EMAIL } } }) @@ -48,11 +52,13 @@ describe('POST /api/auth/register', () => { it('rejects duplicate email', async () => { await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Test123!@' }, }) const res = await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Test123!@' }, }) expect(res.statusCode).toBe(409) @@ -60,7 +66,8 @@ describe('POST /api/auth/register', () => { it('rejects weak password — too short', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Ab1!' }, }) expect(res.statusCode).toBe(400) @@ -70,7 +77,8 @@ describe('POST /api/auth/register', () => { it('rejects weak password — no digit', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Abcdefgh!' }, }) expect(res.statusCode).toBe(400) @@ -79,7 +87,8 @@ describe('POST /api/auth/register', () => { it('rejects weak password — no special char', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: TEST_EMAIL, password: 'Abcdefg1' }, }) expect(res.statusCode).toBe(400) @@ -92,7 +101,8 @@ describe('POST /api/auth/login', () => { beforeAll(async () => { app = await buildApp() await app.inject({ - method: 'POST', url: '/api/auth/register', + method: 'POST', + url: '/api/auth/register', payload: { email: LOGIN_EMAIL, password: 'Test123!@' }, }) }) @@ -106,7 +116,8 @@ describe('POST /api/auth/login', () => { it('logs in with correct password', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/login', + method: 'POST', + url: '/api/auth/login', payload: { email: LOGIN_EMAIL, password: 'Test123!@' }, headers: { 'x-forwarded-for': '1.1.1.1' }, }) @@ -116,7 +127,8 @@ describe('POST /api/auth/login', () => { it('rejects wrong password', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/login', + method: 'POST', + url: '/api/auth/login', payload: { email: LOGIN_EMAIL, password: 'Wrong!!1!' }, headers: { 'x-forwarded-for': '2.2.2.2' }, }) @@ -125,7 +137,8 @@ describe('POST /api/auth/login', () => { it('rejects non-existent email', async () => { const res = await app.inject({ - method: 'POST', url: '/api/auth/login', + method: 'POST', + url: '/api/auth/login', payload: { email: 'nobody@nowhere.test', password: 'Test123!@' }, headers: { 'x-forwarded-for': '3.3.3.3' }, }) diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 66a71c3..82554d9 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -1,10 +1,10 @@ import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js' import { registerAdminNotificationRoutes } from './api/admin/notifications.js' -import { registerAdminProfileRoutes } from './api/admin-profile.js' import { registerAdminCategoryRoutes } from './api/admin-categories.js' import { registerAdminGalleryRoutes } from './api/admin-gallery.js' import { registerAdminOrderRoutes } from './api/admin-orders.js' import { registerAdminProductRoutes } from './api/admin-products.js' +import { registerAdminProfileRoutes } from './api/admin-profile.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminUserRoutes } from './api/admin-users.js' import { registerCatalogSliderRoutes } from './api/catalog-slider.js' diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index eef31db..e30210f 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -73,7 +73,9 @@ export async function registerAdminOrderRoutes(fastify) { const order = await prisma.order.findUnique({ where: { id }, include: { - user: { select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } }, + user: { + select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true }, + }, items: true, messages: { orderBy: { createdAt: 'asc' } }, }, diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index a9d86fe..048c844 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -87,7 +87,9 @@ export async function registerPublicReviewRoutes(fastify) { const total = await prisma.review.count({ where }) const rawItems = await prisma.review.findMany({ where, - include: { user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } } }, + include: { + user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } }, + }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 9fae41b..cfb3c9e 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -105,10 +105,7 @@ export async function registerOAuthSocialRoutes(fastify) { if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const state = fastify.jwt.sign( - { oauth: 'vk', action: 'link', userId: request.user.sub }, - { expiresIn: '15m' }, - ) + const state = fastify.jwt.sign({ oauth: 'vk', action: 'link', userId: request.user.sub }, { expiresIn: '15m' }) const url = new URL('https://oauth.vk.com/authorize') url.searchParams.set('client_id', clientId) @@ -128,13 +125,15 @@ export async function registerOAuthSocialRoutes(fastify) { return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK')) } - let statePayload = null - try { - const raw = typeof query.state === 'string' ? query.state : '' - statePayload = fastify.jwt.verify(raw || '') - } catch { - return oauthErrorRedirect(reply, 'Недействительный state OAuth') - } + const statePayload = (() => { + try { + const raw = typeof query.state === 'string' ? query.state : '' + return fastify.jwt.verify(raw || '') + } catch { + return null + } + })() + if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth') const code = typeof query.code === 'string' ? query.code.trim() : '' if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK') @@ -212,10 +211,7 @@ export async function registerOAuthSocialRoutes(fastify) { if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' }) const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` - const state = fastify.jwt.sign( - { oauth: 'yandex', action: 'link', userId: request.user.sub }, - { expiresIn: '15m' }, - ) + const state = fastify.jwt.sign({ oauth: 'yandex', action: 'link', userId: request.user.sub }, { expiresIn: '15m' }) const url = new URL('https://oauth.yandex.ru/authorize') url.searchParams.set('response_type', 'code') @@ -231,13 +227,15 @@ export async function registerOAuthSocialRoutes(fastify) { const query = request.query ?? {} if (query.error) return oauthErrorRedirect(reply, String(query.error)) - let statePayload = null - try { - const raw = typeof query.state === 'string' ? query.state : '' - statePayload = fastify.jwt.verify(raw || '') - } catch { - return oauthErrorRedirect(reply, 'Недействительный state OAuth') - } + const statePayload = (() => { + try { + const raw = typeof query.state === 'string' ? query.state : '' + return fastify.jwt.verify(raw || '') + } catch { + return null + } + })() + if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth') const code = typeof query.code === 'string' ? query.code.trim() : '' if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс') @@ -276,10 +274,7 @@ export async function registerOAuthSocialRoutes(fastify) { const yaUserId = String(info?.id || '') if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex') - const emailGuess = - (Array.isArray(info?.emails) && info.emails[0]) || - info?.default_email || - null + const emailGuess = (Array.isArray(info?.emails) && info.emails[0]) || info?.default_email || null if (!emailGuess) return oauthErrorRedirect(reply, 'no_email') From 669b9aa45d7e5720734cf133f02564a6c768fca3 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 12:51:41 +0500 Subject: [PATCH 13/23] test commit --- .../src/entities/order/api/admin-order-api.ts | 1 - client/src/entities/review/api/reviews-api.ts | 2 -- client/src/entities/user/api/user-api.ts | 1 - client/src/entities/user/model/types.ts | 1 - .../admin-settings/ui/AdminSettingsPage.tsx | 1 - server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/routes/api/admin-orders.js | 2 +- server/src/routes/api/admin-users.js | 2 -- server/src/routes/api/public-reviews.js | 6 ++---- 9 files changed, 3 insertions(+), 13 deletions(-) diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts index 6e20b8f..9ff8947 100644 --- a/client/src/entities/order/api/admin-order-api.ts +++ b/client/src/entities/order/api/admin-order-api.ts @@ -42,7 +42,6 @@ export type AdminOrderDetailResponse = { email: string displayName: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null } items: Array<{ diff --git a/client/src/entities/review/api/reviews-api.ts b/client/src/entities/review/api/reviews-api.ts index 584198e..badcf9e 100644 --- a/client/src/entities/review/api/reviews-api.ts +++ b/client/src/entities/review/api/reviews-api.ts @@ -27,7 +27,6 @@ export type PublicReviewFeedItem = { createdAt: string authorDisplay: string authorAvatar?: string | null - authorAvatarType?: string | null authorAvatarStyle?: string | null product: { id: string @@ -56,7 +55,6 @@ export type PublicProductReviewItem = { createdAt: string authorDisplay: string authorAvatar?: string | null - authorAvatarType?: string | null authorAvatarStyle?: string | null } diff --git a/client/src/entities/user/api/user-api.ts b/client/src/entities/user/api/user-api.ts index d65bbce..60d3076 100644 --- a/client/src/entities/user/api/user-api.ts +++ b/client/src/entities/user/api/user-api.ts @@ -32,7 +32,6 @@ export async function updateAdminUser( export type AdminAvatarResponse = { avatar: string | null - avatarType: string | null avatarStyle: string | null } diff --git a/client/src/entities/user/model/types.ts b/client/src/entities/user/model/types.ts index 3d00022..2ff1b45 100644 --- a/client/src/entities/user/model/types.ts +++ b/client/src/entities/user/model/types.ts @@ -3,7 +3,6 @@ export type AdminUser = { email: string displayName: string | null avatar?: string | null - avatarType?: string | null avatarStyle?: string | null createdAt: string updatedAt: string diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx index 5bc98fc..bd805e5 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -43,7 +43,6 @@ export function AdminSettingsPage() { email: string displayName: string | null avatar: string | null - avatarType: string | null avatarStyle: string | null }>('admin/profile') return data diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 378bf4ecb0101a90115a70987425334acb3c7c17..438cf286708da727edf656171d9794758ac2fef9 100644 GIT binary patch delta 1619 zcmah}PiWI%6i=H(S8z*3H<(zJIfp~GB~6pG2TdKD;^yjx8#4S;(==_OuIFczLokE05J^!qNMbkMUf?4FytBtR)z1R7vxU`LP&R88+rgiaGZB3su>6gwYFjCa;#sap&3(z_bK=aIbneLdm6lgBN=4w%;tVmI92ONW*9(6qI=mn4cV8_`- z8gJ)kPQ%>MMcm=8TC`dO$5|^^HPeikXTt@?ew8g&!P3S$))|-4ef*V98>M?&065th z$RQ`oI%O2*cYWGR>2<7UY&nVB60|SUeh(`kh}Q!TD|f3vR-wB4N2m&>AP8lnH=OFF z$li%W8Nb|7@~n`2D}SSPgst delta 1650 zcma)6PiWIn7*Co+EvT7@gNaj_a|mNI>C2y1C#iKqWZG?N(ZO)0X_mA}`Zw*`dg#0e zo;FXr>24x|L!3ptsCZHkch;L%PaeDoqVFZ?Ovyyw;d_Vg_kQ2+{l53UrM2FrwceG3 zq22nuLkSq4-h8x7&Czr8`m=pZl5U+KQn;;F>=Cn|W%DDfD8@Lz@qp(z5LfH6RW#L6 zrC^a5hSec(fVhC?3#uh&O&>TGB0$2 zB*#lu&t=jAwuf(GRWWVAwL}%GBZ{~hNTx+P&FEvsJ)P@anMDMM>jcDcB5z4 zHqdsQyd||@d7*$VfI3+dn??BUYo6j@i=E!Axe<1IQVKhZqP!C^7E)QLv1VB{VijHR z62X=@B;R+qTYHIIm*%0CAc6nQ1(kNaIb^Vkir17zbRk_y?|~Yz9t_*NOh2Zdz^~rB zH=d<-OV}%D5ZLNe7e*=4;9U7vD__{s#Q)IaASq940~?{Kzv?M*U# zl)6iQp&!EL-lA9M=g6HcGysFPqtQLv`HJXzT{D#OO}60eeaWC^3KCyF9%0x~;Kb)N H^= Date: Fri, 22 May 2026 13:18:21 +0500 Subject: [PATCH 14/23] feat: load Outfit font from static files --- .../12055-1779436874/state/server.pid | 1 + .../12189-1779436893/state/server.pid | 1 + .../12680-1779437109/state/server.pid | 1 + .../12844-1779437126/state/server.pid | 1 + .../12988-1779437168/state/server.pid | 1 + .../13143-1779437184/state/server.pid | 1 + client/public/fonts/Outfit-Bold.woff2 | Bin 0 -> 14060 bytes client/public/fonts/Outfit-Medium.woff2 | Bin 0 -> 13528 bytes client/public/fonts/Outfit-Regular.woff2 | Bin 0 -> 14032 bytes client/public/fonts/Outfit-SemiBold.woff2 | Bin 0 -> 14140 bytes client/src/app/styles/global.css | 41 +- .../plans/2026-05-22-auth-redesign.md | 1913 +++-------------- .../specs/2026-05-22-auth-redesign-design.md | 419 ++-- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 14 files changed, 548 insertions(+), 1831 deletions(-) create mode 100644 .superpowers/brainstorm/12055-1779436874/state/server.pid create mode 100644 .superpowers/brainstorm/12189-1779436893/state/server.pid create mode 100644 .superpowers/brainstorm/12680-1779437109/state/server.pid create mode 100644 .superpowers/brainstorm/12844-1779437126/state/server.pid create mode 100644 .superpowers/brainstorm/12988-1779437168/state/server.pid create mode 100644 .superpowers/brainstorm/13143-1779437184/state/server.pid create mode 100644 client/public/fonts/Outfit-Bold.woff2 create mode 100644 client/public/fonts/Outfit-Medium.woff2 create mode 100644 client/public/fonts/Outfit-Regular.woff2 create mode 100644 client/public/fonts/Outfit-SemiBold.woff2 diff --git a/.superpowers/brainstorm/12055-1779436874/state/server.pid b/.superpowers/brainstorm/12055-1779436874/state/server.pid new file mode 100644 index 0000000..ad52c0c --- /dev/null +++ b/.superpowers/brainstorm/12055-1779436874/state/server.pid @@ -0,0 +1 @@ +12063 diff --git a/.superpowers/brainstorm/12189-1779436893/state/server.pid b/.superpowers/brainstorm/12189-1779436893/state/server.pid new file mode 100644 index 0000000..8be8535 --- /dev/null +++ b/.superpowers/brainstorm/12189-1779436893/state/server.pid @@ -0,0 +1 @@ +12189 diff --git a/.superpowers/brainstorm/12680-1779437109/state/server.pid b/.superpowers/brainstorm/12680-1779437109/state/server.pid new file mode 100644 index 0000000..32e8255 --- /dev/null +++ b/.superpowers/brainstorm/12680-1779437109/state/server.pid @@ -0,0 +1 @@ +12688 diff --git a/.superpowers/brainstorm/12844-1779437126/state/server.pid b/.superpowers/brainstorm/12844-1779437126/state/server.pid new file mode 100644 index 0000000..aa93a35 --- /dev/null +++ b/.superpowers/brainstorm/12844-1779437126/state/server.pid @@ -0,0 +1 @@ +12844 diff --git a/.superpowers/brainstorm/12988-1779437168/state/server.pid b/.superpowers/brainstorm/12988-1779437168/state/server.pid new file mode 100644 index 0000000..204a445 --- /dev/null +++ b/.superpowers/brainstorm/12988-1779437168/state/server.pid @@ -0,0 +1 @@ +12996 diff --git a/.superpowers/brainstorm/13143-1779437184/state/server.pid b/.superpowers/brainstorm/13143-1779437184/state/server.pid new file mode 100644 index 0000000..5030d63 --- /dev/null +++ b/.superpowers/brainstorm/13143-1779437184/state/server.pid @@ -0,0 +1 @@ +13143 diff --git a/client/public/fonts/Outfit-Bold.woff2 b/client/public/fonts/Outfit-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8674e73594a26b7670d3ba93e42063f39e11c3ee GIT binary patch literal 14060 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 literal 0 HcmV?d00001 diff --git a/client/public/fonts/Outfit-Medium.woff2 b/client/public/fonts/Outfit-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..264ce74280afa9bce3b16dbdd77a42754ba52986 GIT binary patch literal 13528 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 OeG&HNd{LtS0sap##2Lu| literal 0 HcmV?d00001 diff --git a/client/src/app/styles/global.css b/client/src/app/styles/global.css index 0cf9d1c..e89b3b9 100644 --- a/client/src/app/styles/global.css +++ b/client/src/app/styles/global.css @@ -1,13 +1,32 @@ -:root { - color-scheme: light; +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); + font-display: swap; } -html, -body, -#root { - min-height: 100%; -} - -body { - margin: 0; -} +:root { color-scheme: light; } +html, body, #root { min-height: 100%; } +body { margin: 0; } diff --git a/docs/superpowers/plans/2026-05-22-auth-redesign.md b/docs/superpowers/plans/2026-05-22-auth-redesign.md index 4e2cf6f..0b2ffca 100644 --- a/docs/superpowers/plans/2026-05-22-auth-redesign.md +++ b/docs/superpowers/plans/2026-05-22-auth-redesign.md @@ -1,1229 +1,154 @@ -# Auth Redesign — Implementation Plan +# Auth Page Redesign — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Переработать аутентификацию: OAuth только email, внутренние аватары, вход по email+паролю, связывание методов входа в ЛК. +**Goal:** Минималистичный редизайн страницы входа: Outfit локально, BearLogo, Paper-карточка, pill-кнопки, радиальный градиент. -**Architecture:** Server: Fastify + Prisma + bcrypt + in-memory rate limiter. Client: React + Effector + MUI tabs. Email остаётся единым идентификатором. +**Architecture:** MUI sx prop only, замена `client/src/pages/auth/ui/AuthPage.tsx` полностью. Шрифт в `client/public/fonts/` + `@font-face` в `global.css`. -**Tech Stack:** Node.js, Fastify, Prisma, bcrypt, React, MUI, Effector, effector-react +**Tech Stack:** React, MUI, lucide-react (иконки) --- -### Task 1: Install dependencies +### Task 1: Download Outfit font and add @font-face **Files:** -- Modify: `server/package.json` +- Create: `client/public/fonts/Outfit-Regular.woff2` +- Create: `client/public/fonts/Outfit-Medium.woff2` +- Create: `client/public/fonts/Outfit-SemiBold.woff2` +- Create: `client/public/fonts/Outfit-Bold.woff2` +- Modify: `client/src/app/styles/global.css` -- [ ] **Step 1: Install bcrypt** +- [ ] **Step 1: Create fonts directory** ```bash -cd server && npm install bcrypt +mkdir -p /mnt/d/my_projects/shop/client/public/fonts ``` -- [ ] **Step 2: Verify install** +- [ ] **Step 2: Download Outfit woff2 files** ```bash -cd server && node -e "require('bcrypt')" +cd /mnt/d/my_projects/shop/client/public/fonts +curl -sL 'https://fonts.google.com/download?family=Outfit' -o outfit.zip +# OR download individual woff2 files from a CDN: +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-400-normal.woff2' -o Outfit-Regular.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-500-normal.woff2' -o Outfit-Medium.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-600-normal.woff2' -o Outfit-SemiBold.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-700-normal.woff2' -o Outfit-Bold.woff2 ``` -Expected: no error. - ---- - -### Task 2: Prisma schema — remove avatarType - -**Files:** -- Modify: `server/prisma/schema.prisma` - -- [ ] **Step 1: Remove avatarType from User model** - -Edit `server/prisma/schema.prisma`: remove line `avatarType String?`. - -```diff - avatar String? -- avatarType String? - avatarStyle String? -``` - -- [ ] **Step 2: Add data migration comments to migration** - -The migration SQL must also clean up existing OAuth avatar URLs. After Prisma generates the migration, edit the SQL file to include: - -```sql --- Before ALTER TABLE DROP COLUMN: -UPDATE User SET avatar = NULL WHERE avatarType = 'oauth'; -``` - -- [ ] **Step 3: Run migration** +Wait — the jsdelivr URLs may not be exact. Better approach: use `@fontsource/outfit` npm package or download from fontsource CDN: ```bash -cd server && npx prisma migrate dev --name remove_avatarType +cd /mnt/d/my_projects/shop/client/public/fonts +# Outfit Regular (400) +curl -sLo Outfit-Regular.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-400-normal.woff2' +# Outfit Medium (500) +curl -sLo Outfit-Medium.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-500-normal.woff2' +# Outfit SemiBold (600) +curl -sLo Outfit-SemiBold.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-600-normal.woff2' +# Outfit Bold (700) +curl -sLo Outfit-Bold.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-700-normal.woff2' ``` -Check the generated migration SQL in `server/prisma/migrations/`. Edit it if Prisma didn't include the cleanup SQL. - -Expected: migration runs successfully. - -- [ ] **Step 4: Verify Prisma client regenerated** +- [ ] **Step 3: Verify files downloaded** ```bash -cd server && node -e "const {prisma} = require('./src/lib/prisma.js'); prisma.user.findFirst().then(u => { console.log('avatarType' in (u||{})); process.exit(0) })" +ls -la /mnt/d/my_projects/shop/client/public/fonts/ ``` -Expected: `false` (avatarType not in prisma client). +Expected: 4 woff2 files, each > 10KB. ---- +- [ ] **Step 4: Add @font-face to global.css** -### Task 3: Add password validation and bcrypt helpers to lib/auth.js +Read `/mnt/d/my_projects/shop/client/src/app/styles/global.css`. It currently has: -**Files:** -- Modify: `server/src/lib/auth.js` - -- [ ] **Step 1: Add imports and helpers** - -```js -import bcrypt from 'bcrypt' - -const PASSWORD_MIN_LEN = 8 - -const PASSWORD_REGEX = { - letter: /[a-zа-яё]/i, - digit: /[0-9]/, - special: /[^a-zа-яё0-9\s]/i, -} - -export function validatePassword(password) { - if (typeof password !== 'string') return 'Пароль обязателен' - if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов` - if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву' - if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру' - if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол' - return null -} - -export async function hashPassword(password) { - return bcrypt.hash(password, 10) -} - -export async function comparePassword(password, hash) { - return bcrypt.compare(password, hash) -} - -export function isAdminEmail(email) { - const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() - if (!adminEmail) return false - return normalizeEmail(email) === adminEmail -} +```css +:root { color-scheme: light; } +html, body, #root { min-height: 100%; } +body { margin: 0; } ``` ---- +Replace entire file with: -### Task 4: Add in-memory rate limiter for login - -**Files:** -- Create: `server/src/lib/rate-limit.js` - -- [ ] **Step 1: Create rate limiter** - -```js -const windows = new Map() - -const MAX_ATTEMPTS = 5 -const WINDOW_MS = 60_000 - -export function checkLoginRateLimit(ip) { - const now = Date.now() - const entry = windows.get(ip) - if (!entry || now - entry.start > WINDOW_MS) { - windows.set(ip, { start: now, count: 1 }) - return { allowed: true } - } - entry.count += 1 - if (entry.count > MAX_ATTEMPTS) { - const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) - return { allowed: false, retryAfter } - } - return { allowed: true } +```css +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); + font-display: swap; } +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); + font-display: swap; +} + +:root { color-scheme: light; } +html, body, #root { min-height: 100%; } +body { margin: 0; } ``` -- [ ] **Step 2: Test rate limiter** +- [ ] **Step 5: Commit** ```bash -cd server && node -e " -const { checkLoginRateLimit } = require('./src/lib/rate-limit.js'); -checkLoginRateLimit('test1'); checkLoginRateLimit('test1'); -checkLoginRateLimit('test1'); checkLoginRateLimit('test1'); -const r = checkLoginRateLimit('test1'); console.log('5th allowed:', r.allowed); -const r2 = checkLoginRateLimit('test1'); console.log('6th blocked:', !r2.allowed, 'retryAfter:', r2.retryAfter > 0); -" +cd /mnt/d/my_projects/shop +git add client/public/fonts/ client/src/app/styles/global.css +git commit -m "feat: load Outfit font from static files" ``` -Expected: `5th allowed: true`, `6th blocked: true retryAfter: ` - --- -### Task 5: Add register endpoint +### Task 2: Rewrite AuthPage with new design **Files:** -- Modify: `server/src/routes/auth.js` +- Modify: `client/src/pages/auth/ui/AuthPage.tsx` (replace entirely) -- [ ] **Step 1: Add POST /api/auth/register** +- [ ] **Step 1: Read the current file for reference** -Add after existing imports, before `export async function registerAuthRoutes`: +Read `/mnt/d/my_projects/shop/client/src/pages/auth/ui/AuthPage.tsx` — keep the imports, hooks and mutation logic. Only the render JSX changes. -Add import: -```js -import { hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js' -``` +- [ ] **Step 2: Replace AuthPage.tsx** -Add route inside `registerAuthRoutes`, after the `verify-code` route and before `/api/me`: - -```js -fastify.post('/api/auth/register', async (request, reply) => { - const email = normalizeEmail(request.body?.email) - const password = String(request.body?.password || '') - const displayNameRaw = request.body?.displayName - const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0] - - if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' }) - - const passwordErr = validatePassword(password) - if (passwordErr) return reply.code(400).send({ error: passwordErr }) - - const exists = await prisma.user.findUnique({ where: { email } }) - if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) - - const passwordHash = await hashPassword(password) - const user = await prisma.user.create({ - data: { - email, - passwordHash, - displayName: displayName || null, - avatar: null, - avatarStyle: 'avataaars', - }, - }) - - await prisma.notificationPreference.upsert({ - where: { userId: user.id }, - create: { userId: user.id, globalEnabled: true }, - update: {}, - }) - - const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return reply.code(201).send({ token, user: mapUserForClient(user) }) -}) -``` - ---- - -### Task 6: Add login endpoint - -**Files:** -- Modify: `server/src/routes/auth.js` -- Create: `server/src/lib/rate-limit.js` - -- [ ] **Step 1: Add POST /api/auth/login** - -Add import at top of auth.js: -```js -import { comparePassword, isAdminEmail } from '../lib/auth.js' -import { checkLoginRateLimit } from '../lib/rate-limit.js' -``` - -Add route inside `registerAuthRoutes`, after register route: - -```js -fastify.post('/api/auth/login', async (request, reply) => { - const email = normalizeEmail(request.body?.email) - const password = String(request.body?.password || '') - const ip = request.ip - - if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' }) - - const rate = checkLoginRateLimit(ip) - if (!rate.allowed) { - return reply - .code(429) - .header('Retry-After', String(rate.retryAfter)) - .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) - } - - const user = await prisma.user.findUnique({ where: { email } }) - if (!user || !user.passwordHash) { - return reply.code(401).send({ error: 'Неверная почта или пароль' }) - } - - const valid = await comparePassword(password, user.passwordHash) - if (!valid) { - return reply.code(401).send({ error: 'Неверная почта или пароль' }) - } - - const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return { token, user: mapUserForClient(user) } -}) -``` - ---- - -### Task 7: Remove avatarType from mapUserForClient and profile routes - -**Files:** -- Modify: `server/src/routes/auth.js` -- Modify: `server/src/routes/api/admin-profile.js` - -- [ ] **Step 1: Remove avatarType from mapUserForClient** - -Edit `mapUserForClient` in `server/src/routes/auth.js`: - -```diff - avatar: user.avatar, -- avatarType: user.avatarType, - avatarStyle: user.avatarStyle, -``` - -Edit the profile PATCH route in same file, remove all `avatarType` handling: - -```diff - const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() - -- if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { -- return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) -- } - if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) -``` - -Also remove `avatarType` from body destructuring and data object: - -```diff -- const avatarTypeRaw = request.body?.avatarType -- const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() - const avatarStyleRaw = request.body?.avatarStyle -``` - -And in data construction: -```diff -- if (avatarType !== undefined) { -- data.avatarType = avatarType === '' ? null : avatarType -- } -``` - -- [ ] **Step 2: Remove avatarType from admin-profile routes** - -Edit `server/src/routes/api/admin-profile.js`: - -Remove `avatarType` from GET `/api/admin/profile` response (line 14): -```diff - avatar: user.avatar, -- avatarType: user.avatarType, - avatarStyle: user.avatarStyle, -``` - -Remove `avatarType` from GET `/api/admin/avatar` response (line 28): -```diff - avatar: user.avatar, -- avatarType: user.avatarType, - avatarStyle: user.avatarStyle, -``` - -Remove `avatarType` handling from PATCH `/api/admin/profile`: -- Remove destructuring lines for `avatarTypeRaw`/`avatarType` (lines 40-41) -- Remove validation check (lines 48-50) -- Remove `avatarType` from data object (lines 60-62) -- Remove `avatarType` from response (line 76) - ---- - -### Task 8: OAuth — remove profile requests, only email - -**Files:** -- Modify: `server/src/routes/oauth-social.js` - -- [ ] **Step 1: Update findOrCreateUserFromOAuth to remove fallback email** - -Replace `findOrCreateUserFromOAuth` function: - -```js -async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) { - const existingLink = await prisma.oAuthAccount.findUnique({ - where: { provider_providerUserId: { provider, providerUserId } }, - include: { user: true }, - }) - if (existingLink?.user) { - if (accessToken !== undefined) { - await prisma.oAuthAccount.update({ - where: { provider_providerUserId: { provider, providerUserId } }, - data: { accessToken }, - }) - } - return existingLink.user - } - - const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : '' - const norm = trimmed ? normalizeEmail(trimmed) : null - - if (linkToUserId) { - if (!norm) return null - await prisma.oAuthAccount.create({ - data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, - }) - return prisma.user.findUnique({ where: { id: linkToUserId } }) - } - - let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null - if (user) { - await prisma.oAuthAccount.create({ - data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, - }) - return user - } - - if (!norm) return null - - user = await prisma.user.create({ - data: { - email: norm, - displayName: norm.split('@')[0], - avatar: null, - avatarStyle: 'avataaars', - }, - }) - await prisma.oAuthAccount.create({ - data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, - }) - await prisma.notificationPreference.create({ - data: { userId: user.id, globalEnabled: true }, - }) - return user -} -``` - -- [ ] **Step 2: Update VK callback — remove users.get and profile fields** - -Replace VK callback body after token exchange (from line 115), removing the users.get call and profile field extraction: - -```js -const vkUserId = tokenBody?.user_id -const accessTokenVk = tokenBody?.access_token -const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null - -if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') - -// statePayload already parsed in the state verify block above (see Step 4) -const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined - -const user = await findOrCreateUserFromOAuth({ - provider: 'vk', - providerUserId: String(vkUserId), - accessToken: accessTokenVk ?? null, - suggestedEmail: emailSuggestion, - linkToUserId, -}) - -if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') - -if (linkToUserId) { - const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' - return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`) -} - -const token = await issueUserJwt(fastify, user.id, user.email) -return clientRedirect(fastify, reply, token) -``` - -- [ ] **Step 3: Update Yandex callback — remove profile fields, only email** - -Update the authorize scope (line 179): -```diff -- url.searchParams.set('scope', 'login:email login:info') -+ url.searchParams.set('scope', 'login:email') -``` - -Replace Yandex callback body after `/info` call (from line 230), removing profile field extraction: - -```js -const yaUserId = String(info?.id || '') -if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex') - -const emailGuess = - (Array.isArray(info?.emails) && info.emails[0]) || - info?.default_email || - null - -if (!emailGuess) return oauthErrorRedirect(reply, 'no_email') - -// statePayload already parsed in the state verify block (see Step 4) -const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined - -const user = await findOrCreateUserFromOAuth({ - provider: 'yandex', - providerUserId: yaUserId, - accessToken: yaToken, - suggestedEmail: emailGuess, - linkToUserId, -}) - -if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс') - -if (linkToUserId) { - const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' - return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`) -} - -const token = await issueUserJwt(fastify, user.id, user.email) -return clientRedirect(fastify, reply, token) -``` - -- [ ] **Step 4: Update state verify to parse state payload** - -In VK callback, replace lines 89-93: -```js -let statePayload = null -try { - const raw = typeof query.state === 'string' ? query.state : '' - statePayload = fastify.jwt.verify(raw || '') -} catch { - return oauthErrorRedirect(reply, 'Недействительный state OAuth') -} -``` - -In Yandex callback, replace lines 189-194 the same way: -```js -let statePayload = null -try { - const raw = typeof query.state === 'string' ? query.state : '' - statePayload = fastify.jwt.verify(raw || '') -} catch { - return oauthErrorRedirect(reply, 'Недействительный state OAuth') -} -``` - ---- - -### Task 9: OAuth — add link route - -**Files:** -- Modify: `server/src/routes/oauth-social.js` -- Modify: `server/src/plugins/auth.js` - -- [ ] **Step 1: Add GET /api/auth/oauth/{provider}/link** - -Add route in `registerOAuthSocialRoutes`, after each provider's main route but before the callback: - -```js -fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) - if (request.user.email === adminEmail) { - return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) - } - - const clientId = process.env.VK_CLIENT_ID - const clientSecret = process.env.VK_CLIENT_SECRET - if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) - - const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const state = fastify.jwt.sign( - { oauth: 'vk', action: 'link', userId: request.user.sub }, - { expiresIn: '15m' }, - ) - - const url = new URL('https://oauth.vk.com/authorize') - url.searchParams.set('client_id', clientId) - url.searchParams.set('display', 'page') - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'email') - url.searchParams.set('response_type', 'code') - url.searchParams.set('v', '5.199') - url.searchParams.set('state', state) - - return reply.redirect(url.toString()) -}) -``` - -And for Yandex: - -```js -fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) - if (request.user.email === adminEmail) { - return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) - } - - const clientId = process.env.YANDEX_CLIENT_ID - if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' }) - - const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` - const state = fastify.jwt.sign( - { oauth: 'yandex', action: 'link', userId: request.user.sub }, - { expiresIn: '15m' }, - ) - - const url = new URL('https://oauth.yandex.ru/authorize') - url.searchParams.set('response_type', 'code') - url.searchParams.set('client_id', clientId) - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'login:email') - url.searchParams.set('state', state) - - return reply.redirect(url.toString()) -}) -``` - ---- - -### Task 10: Account linking API — auth-methods, password, unlink - -**Files:** -- Modify: `server/src/routes/auth.js` - -- [ ] **Step 1: Add GET /api/me/auth-methods** - -Add route in `registerAuthRoutes`, after `/api/me`: - -```js -fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub - const user = await prisma.user.findUnique({ - where: { id: userId }, - include: { oauthAccounts: { select: { provider: true } } }, - }) - if (!user) return { methods: [] } - - const providers = user.oauthAccounts.map((a) => a.provider) - return { - methods: [ - { type: 'password', active: Boolean(user.passwordHash) }, - { type: 'vk', active: providers.includes('vk') }, - { type: 'yandex', active: providers.includes('yandex') }, - ], - } -}) -``` - -- [ ] **Step 2: Add POST /api/me/password** - -Add route after auth-methods: - -```js -fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - if (isAdminEmail(request.user.email)) { - return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' }) - } - - const user = await prisma.user.findUnique({ where: { id: userId } }) - if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) - if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' }) - - const password = String(request.body?.password || '') - const passwordErr = validatePassword(password) - if (passwordErr) return reply.code(400).send({ error: passwordErr }) - - const passwordHash = await hashPassword(password) - await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) - - return { ok: true } -}) -``` - -- [ ] **Step 3: Add DELETE /api/me/oauth/{provider}** - -Add route after /api/me/password: - -```js -fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const provider = request.params?.provider - - if (isAdminEmail(request.user.email)) { - return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' }) - } - if (provider !== 'vk' && provider !== 'yandex') { - return reply.code(400).send({ error: 'Неизвестный провайдер' }) - } - - const oauth = await prisma.oAuthAccount.findFirst({ - where: { userId, provider }, - }) - if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' }) - - const remainingOAuth = await prisma.oAuthAccount.count({ - where: { userId, provider: { not: provider } }, - }) - const user = await prisma.user.findUnique({ where: { id: userId }, select: { passwordHash: true } }) - if (!user?.passwordHash && remainingOAuth === 0) { - return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' }) - } - - await prisma.oAuthAccount.delete({ where: { id: oauth.id } }) - return { ok: true } -}) -``` - ---- - -### Task 11: Server tests — password auth endpoints - -**Files:** -- Create: `server/src/routes/__tests__/auth-password.test.js` - -- [ ] **Step 1: Write tests** - -```js -import Fastify from 'fastify' -import jwt from '@fastify/jwt' -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' -import { prisma } from '../../lib/prisma.js' -import { registerAuthRoutes } from '../auth.js' - -const JWT_SECRET = 'test-secret' -const TEST_EMAIL = `test-reg-${Date.now()}@example.com` - -async function buildApp() { - const app = Fastify({ logger: false }) - await app.register(jwt, { secret: JWT_SECRET }) - app.decorate('authenticate', async function (request, reply) { - try { - await request.jwtVerify() - } catch { - return reply.code(401).send({ error: 'Unauthorized' }) - } - }) - app.decorate('eventBus', { emit: () => {} }) - await registerAuthRoutes(app) - await app.ready() - return app -} - -describe('POST /api/auth/register', () => { - let app - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) - afterEach(async () => { - await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }) - }) - - it('registers a new user with password', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Test123!@' }, - }) - expect(res.statusCode).toBe(201) - const body = JSON.parse(res.body) - expect(body.token).toBeTruthy() - expect(body.user.email).toBe(TEST_EMAIL) - expect(body.user.displayName).toBe('test-reg') - }) - - it('rejects duplicate email', async () => { - await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Test123!@' }, - }) - const res = await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Test123!@' }, - }) - expect(res.statusCode).toBe(409) - }) - - it('rejects weak password — too short', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Ab1!' }, - }) - expect(res.statusCode).toBe(400) - const body = JSON.parse(res.body) - expect(body.error).toContain('не менее 8') - }) - - it('rejects weak password — no digit', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Abcdefgh!' }, - }) - expect(res.statusCode).toBe(400) - expect(JSON.parse(res.body).error).toContain('цифру') - }) - - it('rejects weak password — no special char', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Abcdefg1' }, - }) - expect(res.statusCode).toBe(400) - expect(JSON.parse(res.body).error).toContain('спецсимвол') - }) -}) - -describe('POST /api/auth/login', () => { - let app - const loginEmail = `test-login-${Date.now()}@example.com` - - beforeAll(async () => { - app = await buildApp() - await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: loginEmail, password: 'Test123!@' }, - }) - }) - afterAll(async () => { - await prisma.user.deleteMany({ where: { email: loginEmail } }) - await app.close() - }) - - it('logs in with correct password', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/login', - payload: { email: loginEmail, password: 'Test123!@' }, - headers: { 'x-forwarded-for': '1.1.1.1' }, - }) - expect(res.statusCode).toBe(200) - expect(JSON.parse(res.body).token).toBeTruthy() - }) - - it('rejects wrong password', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/login', - payload: { email: loginEmail, password: 'Wrong!!1!' }, - headers: { 'x-forwarded-for': '2.2.2.2' }, - }) - expect(res.statusCode).toBe(401) - }) - - it('rejects non-existent email', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/login', - payload: { email: 'nobody@nowhere.test', password: 'Test123!@' }, - headers: { 'x-forwarded-for': '3.3.3.3' }, - }) - expect(res.statusCode).toBe(401) - }) - - it('returns 403 for admin email', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/login', - payload: { email: process.env.ADMIN_EMAIL || 'admin@test.local', password: 'Test123!@' }, - headers: { 'x-forwarded-for': '4.4.4.4' }, - }) - if (process.env.ADMIN_EMAIL) { - expect(res.statusCode).toBe(403) - } - }) -}) -``` - -- [ ] **Step 2: Run tests** - -```bash -cd server && npx vitest run src/routes/__tests__/auth-password.test.js -``` - -Expected: all tests pass. - ---- - -### Task 12: Server tests — auth-methods, password, unlink - -**Files:** -- Create: `server/src/routes/__tests__/auth-methods.test.js` - -- [ ] **Step 1: Write tests** - -```js -import Fastify from 'fastify' -import jwt from '@fastify/jwt' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { prisma } from '../../lib/prisma.js' -import { registerAuthRoutes } from '../auth.js' - -const JWT_SECRET = 'test-secret' - -async function buildApp() { - const app = Fastify({ logger: false }) - await app.register(jwt, { secret: JWT_SECRET }) - app.decorate('authenticate', async function (request, reply) { - try { await request.jwtVerify() } catch { - return reply.code(401).send({ error: 'Unauthorized' }) - } - }) - app.decorate('eventBus', { emit: () => {} }) - await registerAuthRoutes(app) - await app.ready() - return app -} - -function signToken(app, userId, email) { - return app.jwt.sign({ sub: userId, email }) -} - -async function createUser(email) { - const user = await prisma.user.create({ - data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' }, - }) - await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } }) - return user -} - -describe('GET /api/me/auth-methods', () => { - let app, user, token - const email = `test-methods-${Date.now()}@example.com` - - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) - - beforeEach(async () => { - await prisma.user.deleteMany({ where: { email } }) - user = await createUser(email) - token = signToken(app, user.id, email) - }) - - it('returns methods for user without any method', async () => { - const res = await app.inject({ - method: 'GET', url: '/api/me/auth-methods', - headers: { authorization: `Bearer ${token}` }, - }) - expect(res.statusCode).toBe(200) - const body = JSON.parse(res.body) - expect(body.methods.find((m) => m.type === 'password').active).toBe(false) - expect(body.methods.find((m) => m.type === 'vk').active).toBe(false) - expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false) - }) - - it('returns password as active after setting it', async () => { - await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) - const res = await app.inject({ - method: 'GET', url: '/api/me/auth-methods', - headers: { authorization: `Bearer ${token}` }, - }) - expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true) - }) -}) - -describe('POST /api/me/password', () => { - let app, user, token - const email = `test-set-pw-${Date.now()}@example.com` - - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) - - beforeEach(async () => { - await prisma.user.deleteMany({ where: { email } }) - user = await createUser(email) - token = signToken(app, user.id, email) - }) - - it('sets password', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/me/password', - headers: { authorization: `Bearer ${token}` }, - payload: { password: 'Test123!@' }, - }) - expect(res.statusCode).toBe(200) - - const u = await prisma.user.findUnique({ where: { id: user.id } }) - expect(u.passwordHash).toBeTruthy() - }) - - it('rejects if password already set', async () => { - await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } }) - const res = await app.inject({ - method: 'POST', url: '/api/me/password', - headers: { authorization: `Bearer ${token}` }, - payload: { password: 'Test123!@' }, - }) - expect(res.statusCode).toBe(409) - }) -}) - -describe('DELETE /api/me/oauth/:provider', () => { - let app, user, token - const email = `test-unlink-${Date.now()}@example.com` - - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) - - beforeEach(async () => { - await prisma.user.deleteMany({ where: { email } }) - user = await createUser(email) - token = signToken(app, user.id, email) - }) - - it('returns 404 for non-linked provider', async () => { - const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', - headers: { authorization: `Bearer ${token}` }, - }) - expect(res.statusCode).toBe(404) - }) - - it('unlinks a provider', async () => { - await prisma.oAuthAccount.create({ - data: { provider: 'vk', providerUserId: '123', userId: user.id }, - }) - const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', - headers: { authorization: `Bearer ${token}` }, - }) - expect(res.statusCode).toBe(200) - - const count = await prisma.oAuthAccount.count({ where: { userId: user.id } }) - expect(count).toBe(0) - }) - - it('rejects removing last method without password', async () => { - await prisma.oAuthAccount.create({ - data: { provider: 'vk', providerUserId: '123', userId: user.id }, - }) - const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', - headers: { authorization: `Bearer ${token}` }, - }) - expect(res.statusCode).toBe(400) - expect(JSON.parse(res.body).error).toContain('последний метод') - }) -}) -``` - -- [ ] **Step 2: Run tests** - -```bash -cd server && npx vitest run src/routes/__tests__/auth-methods.test.js -``` - -Expected: all tests pass. - ---- - -### Task 13: Run all server tests together - -- [ ] **Step 1: Run full server test suite** - -```bash -cd server && npx vitest run -``` - -Expected: all existing and new tests pass. - ---- - -### Task 14: Client — update Effector auth model - -**Files:** -- Modify: `client/src/shared/model/auth.ts` - -- [ ] **Step 1: Remove avatarType from AuthUser type and add new effects** - -```ts -import { createEffect, createEvent, createStore, sample } from 'effector' -import { apiClient } from '@/shared/api/client' -import { createErrorStore } from '@/shared/lib/create-error-store' -import { persistToken } from '@/shared/lib/persist-token' - -export type AuthUser = { - id: string - email: string - displayName?: string | null - firstName?: string | null - lastName?: string | null - gender?: string | null - avatar?: string | null - avatarStyle?: string | null - isAdmin?: boolean -} - -export type AuthMethod = { - type: 'password' | 'vk' | 'yandex' - active: boolean -} - -export const tokenSet = createEvent() -export const logout = createEvent() - -// ----- Token persistence ----- - -const persistTokenFx = createEffect({ - handler: (token) => persistToken(token), -}) - -export const $token = createStore(null) - .on(tokenSet, (_, t) => t) - .reset(logout) - -sample({ clock: $token, target: persistTokenFx }) - -// ----- User ----- - -export const $user = createStore(null).reset(logout) - -export const meFx = createEffect(async (token: string) => { - const { data } = await apiClient.get<{ user: AuthUser | null }>('me', { - headers: { Authorization: `Bearer ${token}` }, - }) - return data.user -}) - -sample({ clock: tokenSet, filter: (t): t is string => Boolean(t), target: meFx }) - -sample({ clock: meFx.doneData, target: $user }) - -// ----- Email change ----- - -export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => { - await apiClient.post('me/change-email/request-code', { newEmail }) -}) - -export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => { - const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params) - return data.user -}) - -// ----- Profile update ----- - -export type UpdateProfileParams = { - displayName: string | null - avatar?: string | null - avatarStyle?: string | null -} - -export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => { - const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) - return data.user -}) - -// ----- Login / Register ----- - -export const loginFx = createEffect(async (params: { email: string; password: string }) => { - const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/login', params) - tokenSet(data.token) - return data.user -}) - -export const registerFx = createEffect( - async (params: { email: string; password: string; displayName?: string }) => { - const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) - tokenSet(data.token) - return data.user - }, -) - -// ----- Auth methods ----- - -export const fetchAuthMethodsFx = createEffect(async () => { - const { data } = await apiClient.get<{ methods: AuthMethod[] }>('me/auth-methods') - return data.methods -}) - -export const setPasswordFx = createEffect(async (password: string) => { - await apiClient.post('me/password', { password }) -}) - -export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => { - await apiClient.delete(`me/oauth/${provider}`) -}) - -// ----- Error stores ----- - -export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error -export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error -export const $updateProfileError = createErrorStore(updateProfileFx).$error - -// ----- Re-exports ----- - -export { readStoredToken } from '@/shared/lib/persist-token' - -// ----- Sync user from profile/email changes ----- - -sample({ clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData], target: $user }) -``` - ---- - -### Task 15: Client — update UserAvatar (remove avatarType) - -**Files:** -- Modify: `client/src/shared/ui/UserAvatar.tsx` - -- [ ] **Step 1: Remove avatarType prop and always use DiceBear fallback** - -```tsx -import { useMemo } from 'react' -import Avatar from '@mui/material/Avatar' -import type { SxProps, Theme } from '@mui/material/styles' -import { createAvatar } from '@dicebear/core' -import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' - -type UserAvatarProps = { - userId: string - avatarUrl?: string | null - avatarStyle?: string | null - size?: number - sx?: SxProps -} - -export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { - const generatedSrc = useMemo(() => { - const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) - const avatar = createAvatar(styleDef.style, { seed: userId }) - return avatar.toDataUri() - }, [userId, avatarStyle]) - - const src = avatarUrl || generatedSrc - - return ( - - ? - - ) -} -``` - ---- - -### Task 16: Client — update UserAvatar usages (remove avatarType prop) - -- [ ] **Step 1: Find and update all UserAvatar usages** - -Search for all `avatarType` passed to `UserAvatar` and remove them. Files to modify: - -- `client/src/pages/me/ui/sections/SettingsPage.tsx` — lines 131, 148 (remove `avatarType` prop) -- `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` — lines 147, 164 (remove `avatarType` prop) -- `client/src/features/user/user-menu/ui/UserMenu.tsx` — check for UserAvatar usage -- Any other files using UserAvatar with avatarType - -Remove `avatarType` prop from each `` usage. The prop no longer exists on the component. - ---- - -### Task 17: Client — rewrite AuthPage with tabs - -**Files:** -- Modify: `client/src/pages/auth/ui/AuthPage.tsx` - -- [ ] **Step 1: Rewrite complete AuthPage** +Write the entire file: ```tsx import { useEffect, useState } from 'react' +import { alpha, useTheme } from '@mui/material/styles' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import Paper from '@mui/material/Paper' import Stack from '@mui/material/Stack' -import Tab from '@mui/material/Tab' -import Tabs from '@mui/material/Tabs' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' +import { Lock, Mail } from 'lucide-react' import { useForm } from 'react-hook-form' import { useNavigate, useSearchParams } from 'react-router-dom' import { OAuthButtons } from '@/features/auth-oauth' import { apiClient } from '@/shared/api/client' -import { $user, loginFx, registerFx, tokenSet } from '@/shared/model/auth' +import { $user, tokenSet } from '@/shared/model/auth' +import { BearLogo } from '@/shared/ui/BearLogo' type AuthResponse = { token: string @@ -1246,6 +171,7 @@ function getApiErrorMessage(err: unknown): string | null { } export function AuthPage() { + const theme = useTheme() const [message, setMessage] = useState(null) const [oauthError, setOauthError] = useState(null) const [tab, setTab] = useState(0) @@ -1326,450 +252,291 @@ export function AuthPage() { isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null return ( - - - Вход / регистрация - + + + + + - {message && {message}} - {oauthError && ( - setOauthError(null)}>{oauthError} - )} - {errMsg && {errMsg}} + + Добро пожаловать в Любимый Креатив + - setTab(v)} sx={{ mb: 3 }}> - - - - + + Войдите или зарегистрируйтесь, чтобы продолжить + - {tab === 0 && ( - - - - + + + {[ + { label: 'Пароль', idx: 0 }, + { label: 'Код', idx: 1 }, + { label: 'Другой способ', idx: 2 }, + ].map(({ label, idx }) => ( + + ))} - - - {isRegister && ( - - )} - - - - {isRegister && ( - - )} - - {isRegister ? ( - - ) : ( - - )} - - )} - - {tab === 1 && ( - - - - - - - - - )} + {errMsg || oauthError} + + )} + {message && ( + setMessage(null)}> + {message} + + )} - {tab === 2 && ( - - - - )} + {tab === 0 && ( + + + + + + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} + + )} + + {tab === 1 && ( + + + + + ), + }, + }} + /> + + + + + + + )} + + {tab === 2 && ( + + + + )} + + ) } ``` ---- +- [ ] **Step 3: Run typecheck** -### Task 18: Client — add auth methods section to SettingsPage - -**Files:** -- Modify: `client/src/pages/me/ui/sections/SettingsPage.tsx` - -- [ ] **Step 1: Simplify avatar section — remove OAuth avatar switching** - -Replace the avatar section's state variables (lines 59-61): -```tsx -const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') -const useOAuth = user?.avatarType === 'oauth' -const useGenerated = user?.avatarType === 'generated' +```bash +cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -20 ``` -With: -```tsx -// no more avatarType — always internal avatars +Expected: no errors. + +- [ ] **Step 4: Run tests** + +```bash +cd /mnt/d/my_projects/shop/client && npx vitest run ``` -And replace the caption (lines 140): -```diff -- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} -+ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} -``` +Expected: all tests pass (7 files, 29 tests). -Remove the "Use OAuth" button block entirely (lines 208-222): -```diff -- {hasOAuthAvatar && !hasUnsavedPreview && ( -- -- )} -``` +- [ ] **Step 5: Commit** -And in UserAvatar usage, remove `avatarType` prop (lines 131, 148): -```diff -- avatarType={hasUnsavedPreview ? 'generated' : user.avatarType} -``` -(just delete that prop line) - -Remove `avatarType` from updateProfileFx calls (lines 191-197 and any other): -```diff - updateProfileFx({ - displayName: user.displayName?.trim() || null, - avatar: previewSrc, -- avatarType: 'generated', - avatarStyle: previewStyle, - }) -``` - -- [ ] **Step 2: Add auth methods section — imports and state** - -Add imports at top: -```tsx -import { useCallback } from 'react' -import Chip from '@mui/material/Chip' -import { - fetchAuthMethodsFx, - setPasswordFx, - unlinkOAuthFx, - type AuthMethod, -} from '@/shared/model/auth' -``` - -Add state and data loading after existing hooks: -```tsx -const [authMethods, setAuthMethods] = useState([]) -const [showSetPassword, setShowSetPassword] = useState(false) -const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ - defaultValues: { password: '', passwordConfirm: '' }, -}) - -useEffect(() => { - fetchAuthMethodsFx().then(setAuthMethods).catch(() => { - setAuthMethods([]) - }) -}, []) - -const setPasswordMutation = useMutation({ - mutationFn: async (pw: string) => { - await setPasswordFx(pw) - const methods = await fetchAuthMethodsFx() - setAuthMethods(methods) - setShowSetPassword(false) - }, - onError: () => {}, -}) - -const unlinkMutation = useMutation({ - mutationFn: async (provider: 'vk' | 'yandex') => { - await unlinkOAuthFx(provider) - const methods = await fetchAuthMethodsFx() - setAuthMethods(methods) - }, - onError: () => {}, -}) - -const linkedCount = useCallback(() => { - return authMethods.filter((m) => m.active).length -}, [authMethods]) - -const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } -``` - -- [ ] **Step 3: Add auth methods section UI** - -Insert after the avatar section's closing `` + `` (before email change section), but only if `!user.isAdmin`: - -```tsx -{!user.isAdmin && ( - <> - - - - Методы входа - - - {authMethods.map((m) => ( - - {METHOD_LABELS[m.type] || m.type} - - {m.active && m.type !== 'password' && ( - - )} - {!m.active && m.type === 'password' && ( - - )} - {!m.active && m.type !== 'password' && ( - - )} - - ))} - - - {showSetPassword && ( - - - - - - - - - )} - - -)} +```bash +cd /mnt/d/my_projects/shop +git add client/src/pages/auth/ui/AuthPage.tsx +git commit -m "feat(client): redesign auth page with minimal style, BearLogo, pill buttons" ``` --- -### Task 19: Client — update AdminSettingsPage (remove avatarType) +### Task 3: Run full verification -**Files:** -- Modify: `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` - -- [ ] **Step 1: Remove avatarType and OAuth avatar references** - -The AdminSettingsPage mirrors SettingsPage. Make these specific changes: - -1. Remove state lines (find the equivalent of `hasOAuthAvatar`, `useOAuth`, `useGenerated`): -```diff -- const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') -- const useOAuth = user?.avatarType === 'oauth' -- const useGenerated = user?.avatarType === 'generated' -``` - -2. Remap the caption line: -```diff -- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} -+ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} -``` - -3. Remove the "Use OAuth" button block (find lines starting with `{hasOAuthAvatar && !hasUnsavedPreview`). - -4. Remove `avatarType` prop from all `` usages in this file. - -5. Remove `avatarType` from any `updateProfileFx` or admin profile API calls in this file. Find the PATCH call payload and remove the `avatarType` field. - ---- - -### Task 20: Client tests - -**Files:** -- Create: `client/src/pages/auth/__tests__/AuthPage.test.tsx` - -- [ ] **Step 1: Write AuthPage tests** - -```tsx -import { render, screen, fireEvent } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { MemoryRouter } from 'react-router-dom' -import { describe, expect, it, vi } from 'vitest' -import { AuthPage } from '../ui/AuthPage' - -vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) -vi.mock('effector-react', async () => { - const actual = await vi.importActual('effector-react') - return { ...actual, useUnit: () => null } -}) - -function renderPage() { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return render( - - - - - , - ) -} - -describe('AuthPage', () => { - it('renders three tabs', () => { - renderPage() - expect(screen.getByText('Пароль')).toBeTruthy() - expect(screen.getByText('Код')).toBeTruthy() - expect(screen.getByText('Другой способ')).toBeTruthy() - }) - - it('shows login form by default on tab 0', () => { - renderPage() - expect(screen.getByText('Вход')).toBeTruthy() - expect(screen.getByText('Регистрация')).toBeTruthy() - const buttons = screen.getAllByRole('button') - const loginBtn = buttons.find((b) => b.textContent === 'Войти') - expect(loginBtn).toBeTruthy() - }) - - it('switches to register form', () => { - renderPage() - fireEvent.click(screen.getByText('Регистрация')) - expect(screen.getByText('Зарегистрироваться')).toBeTruthy() - }) - - it('switches to code tab', () => { - renderPage() - fireEvent.click(screen.getByText('Код')) - expect(screen.getByText('Отправить код')).toBeTruthy() - }) - - it('switches to OAuth tab', () => { - renderPage() - fireEvent.click(screen.getByText('Другой способ')) - expect(screen.getByText('Войти через VK ID')).toBeTruthy() - expect(screen.getByText('Войти через Яндекс ID')).toBeTruthy() - }) -}) -``` - -- [ ] **Step 2: Run client tests** +- [ ] **Step 1: Client lint + format + build** ```bash -cd client && npx vitest run src/pages/auth/__tests__/AuthPage.test.tsx +cd /mnt/d/my_projects/shop/client +npm run lint +npm run format:check +npm run build ``` -Expected: all 5 tests pass. +Expected: 0 errors, format clean, build success. ---- - -### Task 21: Run full test suite - -- [ ] **Step 1: Run server tests** +- [ ] **Step 2: Server tests (regression check)** ```bash -cd server && npx vitest run +cd /mnt/d/my_projects/shop/server && npx vitest run ``` -- [ ] **Step 2: Run client tests** +Expected: all pass (ignore pre-existing user-payments.test.js failures if any). + +- [ ] **Step 3: Commit if anything changed** ```bash -cd client && npx vitest run +cd /mnt/d/my_projects/shop +git add -A +git diff --cached --quiet || git commit -m "chore: post-redesign lint fixes" ``` - -- [ ] **Step 3: Run client lint + format check** - -```bash -cd client && npm run lint && npm run format:check -``` - -- [ ] **Step 4: Run client build** - -```bash -cd client && npm run build -``` - -All must pass. diff --git a/docs/superpowers/specs/2026-05-22-auth-redesign-design.md b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md index af2cad9..6bf2444 100644 --- a/docs/superpowers/specs/2026-05-22-auth-redesign-design.md +++ b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md @@ -1,300 +1,225 @@ -# Auth Redesign — Spec +# Auth Page Redesign — Spec **Date:** 2026-05-22 -**Goal:** Переработать систему аутентификации: OAuth запрашивает только email, убрать внешние аватары, добавить вход по email+паролю, дать пользователям связывать методы входа в ЛК. +**Goal:** Минималистичный редизайн страницы входа с лёгким брендингом (медведь + слоган), pill-кнопками и Paper-карточкой. + +**Style:** Минималистичный, чистый. Одна колонка по центру. --- -## 1. Data Model (Prisma) +## 1. Шрифт Outfit -### 1.1. Модель `User` — изменения +**Проблема:** Outfit указан в MUI-теме, но не загружается. Фактически везде системный Segoe UI. -| Поле | Было | Стало | -|------|------|-------| -| `passwordHash` | `String?` (не использовалось) | Задействуем. Хранит bcrypt-хеш. `null` если пароль не установлен. | -| `avatarType` | `String?` (`'oauth'` / `'generated'`) | **Удалить.** Все аватары внутренние (DiceBear). | -| `avatar` | `String?` (URL или data:uri) | Только DiceBear URL или `null` (генерируется на лету) | -| `avatarStyle` | `String?` | Без изменений. | +**Исправление:** Скачать шрифт Outfit (woff2, веса 400/500/600/700) и разместить в `client/public/fonts/`. Добавить `@font-face` в `client/src/app/styles/global.css`: -Остальные поля (`id`, `email`, `displayName`, `firstName`, `lastName`, `gender`, `createdAt`, `updatedAt`) — без изменений. `firstName`, `lastName`, `gender` больше не заполняются при OAuth, но остаются в БД (могут быть заполнены пользователем вручную позже). - -### 1.2. Модель `OAuthAccount` — без изменений - -```prisma -model OAuthAccount { - id String @id @default(cuid()) - provider String // 'vk' | 'yandex' - providerUserId String - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - accessToken String? - refreshToken String? // зарезервировано, не используется сейчас - createdAt DateTime @default(now()) - - @@unique([provider, providerUserId]) +```css +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); } ``` -### 1.3. Модель `AuthCode` — без изменений - -### 1.4. Миграции - -1. Удаление колонки `avatarType` из `User` -2. Миграция данных: для всех пользователей с `avatarType = 'oauth'` установить `avatar = null` (внешние URL больше не используются, аватар перегенерируется DiceBear) +Файлы woff2 скачать с Google Fonts или из CDN и положить в `client/public/fonts/`. --- -## 2. Авторизация по email+паролю +## 2. Фон страницы -### 2.1. Регистрация - -`POST /api/auth/register` (новый, без аутентификации) - -**Request:** -```json -{ - "email": "user@example.com", - "password": "Abcdef1!", - "displayName": "Иван" // optional -} -``` - -**Валидация:** -- `email`: валидный email, нормализация (trim + lowercase), уникальность -- `password`: минимум 8 символов, минимум 1 буква, 1 цифра, 1 спецсимвол -- `displayName`: опционально, строка до 100 символов. Если не передан — берётся часть email до `@` - -**Логика:** -1. Проверка, что email не занят → 409 если занят -2. `passwordHash = await bcrypt.hash(password, 10)` -3. Создание пользователя: `email`, `passwordHash`, `displayName`, `avatar = null`, `avatarStyle = 'avataaars'` -4. Создание `NotificationPreference` (как сейчас в verify-code) -5. Возврат JWT + user - -**Response 201:** -```json -{ - "token": "jwt...", - "user": { "id", "email", "displayName", "avatar", "avatarStyle", "isAdmin": false } -} -``` - -### 2.2. Вход - -`POST /api/auth/login` (новый, без аутентификации) - -**Request:** -```json -{ - "email": "user@example.com", - "password": "Abcdef1!" -} -``` - -**Rate limit:** максимум 5 попыток в минуту с одного IP (использовать `@fastify/rate-limit`). При превышении — `429 Too Many Requests`. - -**Логика:** -1. Нормализация email -2. Поиск пользователя по email -3. Если пользователь не найден ИЛИ `passwordHash === null` → `401 Invalid email or password` (одинаковый ответ для безопасности) -4. `await bcrypt.compare(password, user.passwordHash)` → если не совпадает → `401` -5. Возврат JWT + user - -### 2.3. Админ и пароль - -- Админ (`email === ADMIN_EMAIL`) **не может** зарегистрироваться или войти по паролю -- `POST /api/auth/register` и `POST /api/auth/login` возвращают `403` для админского email -- Админ также **не может** установить пароль через `POST /api/me/password` +- `background.default` + лёгкий радиальный градиент +- Градиент: от центра к краям, `primary.main` с 3-5% opacity +- Реализация: `sx` prop на корневом ``: `background: radial-gradient(circle at 50% 30%, ${alpha(theme.palette.primary.main, 0.05)} 0%, transparent 70%)` --- -## 3. OAuth (только email) - -### 3.1. Scope - -| Провайдер | Было | Стало | -|-----------|------|-------| -| VK | `email` | `email` (без изменений, но больше не запрашиваем профиль) | -| Яндекс | `login:email login:info` | `login:email` | - -### 3.2. Callback — что убираем - -**VK:** -- Больше не делаем `users.get` после получения токена -- Не сохраняем: `first_name`, `last_name`, `photo_200`, `sex` - -**Яндекс:** -- Всё ещё вызываем `GET https://login.yandex.ru/info` (нужен для получения email) -- Из ответа берём только `default_email` или первый из `emails` -- **Не сохраняем:** `first_name`, `last_name`, `display_name`, `sex`, `default_avatar_id` - -### 3.3. Callback — новая логика +## 3. Компоновка ``` -1. Обмен code на access_token (как сейчас) -2. Извлечение email из ответа провайдера: - - VK: поле `email` в ответе access_token - - Яндекс: вызываем `/info`, из ответа берём `default_email` или первый из `emails` -3. Если email отсутствует → редирект с ?oauthError=no_email -4. Нормализация email -5. Поиск пользователя по email: - a) Найден → привязываем OAuthAccount (если ещё не привязан), возвращаем JWT - b) Не найден → создаём нового: - - email - - displayName = часть email до @ - - avatar = null - - avatarStyle = 'avataaars' - - Создаём OAuthAccount - - Создаём NotificationPreference - - Возвращаем JWT -6. Редирект на CLIENT_PUBLIC_URL/auth/callback?token=... +┌──────────────────────────────────────────┐ +│ (воздух) │ +│ 🐻 BearLogo 72px │ +│ Добро пожаловать в Любимый Креатив │ +│ (subtitle, text.secondary) │ +│ (воздух) │ +│ ┌─────── Paper 440px max-width ──────┐ │ +│ │ [Пароль] [Код] [Другой способ] │ │ +│ │ │ │ +│ │ Вход / Регистрация │ │ +│ │ │ │ +│ │ Email: __________________ │ │ +│ │ Пароль: __________________ │ │ +│ │ │ │ +│ │ [────────── Войти ──────────] │ │ +│ │ │ │ +│ └─────────────────────────────────────┘ │ +│ (воздух) │ +└──────────────────────────────────────────┘ ``` -**Fallback-email `{provider}_{id}@oauth.craftshop.local` — убираем.** Если провайдер не дал email — ошибка. - -### 3.4. State-параметр - -Без изменений. JWT с `expiresIn: 15m` для CSRF-защиты. +Детали: +- Корневой ``: `display: flex, alignItems: center, justifyContent: center, minHeight: calc(100vh - header)` +- BearLogo: `` +- Заголовок: `variant="h5"`, `fontWeight: 700`, `textAlign: center` +- Слоган: `variant="body2"`, `color: text.secondary`, `textAlign: center`, `mb: 3` +- Paper: `maxWidth: 440`, `mx: auto`, `p: 4`, `borderRadius: 3` (12px), `border: 1px solid divider`, мягкая тень --- -## 4. Связывание аккаунтов +## 4. Pill-переключатель методов -### 4.1. Авто-связывание +Вместо MUI Tabs — три MUI Button в ряд: -При OAuth-входе: если email из OAuth совпадает с email существующего пользователя — автоматически создаётся `OAuthAccount`, связывающий провайдера с пользователем. Вход происходит мгновенно. - -### 4.2. Ручное связывание — страница настроек `/me` - -**Получение статуса методов:** `GET /api/me/auth-methods` (новый, требует `authenticate`) - -**Response:** -```json -{ - "methods": [ - { "type": "password", "active": true }, - { "type": "vk", "active": false }, - { "type": "yandex", "active": true } - ] -} +```tsx + + + + + ``` -Логика: -- `type: "password"` — `active: user.passwordHash !== null` -- `type: "vk"` / `type: "yandex"` — `active: exists(OAuthAccount)` +--- -### 4.3. Привязка OAuth +## 5. Под-переключатель Вход/Регистрация -`GET /api/auth/oauth/{provider}/link` (новый, требует `authenticate`) +Только на вкладке «Пароль»: -1. Генерирует state-JWT с `{ userId, provider, action: 'link' }`, `expiresIn: 15m` -2. Редиректит на страницу авторизации провайдера -3. После callback проверяет `action: 'link'` в state -4. Создаёт `OAuthAccount` для указанного `userId` (нормальный upsert) -5. Редиректит на `/me?linked={provider}` - -### 4.4. Привязка пароля - -`POST /api/me/password` (новый, требует `authenticate`) - -**Request:** `{ "password": "Abcdef1!" }` - -**Логика:** -1. Если `user.email === ADMIN_EMAIL` → `403` -2. Если `user.passwordHash !== null` → `409 Password already set` (для смены использовать отдельный метод) -3. Валидация пароля (8+, буква+цифра+спецсимвол) -4. `user.passwordHash = await bcrypt.hash(password, 10)` -5. Сохранение пользователя - -### 4.5. Отвязывание - -`DELETE /api/me/oauth/{provider}` (новый, требует `authenticate`) - -**Логика:** -1. Удаление `OAuthAccount` по `userId + provider` -2. Если `OAuthAccount` не найден → `404` -3. **Проверка последнего метода:** после удаления, если у пользователя нет ни `passwordHash`, ни других `OAuthAccount` → `400 Cannot remove last auth method` -4. Возврат `200` - -**Примечание:** отвязывание пароля (установка `passwordHash = null`) пока не делаем — можно добавить позже. - -### 4.6. Админ и связывание - -- `POST /api/me/password` → `403` для админа -- OAuth-привязка через `/link` → `403` для админа -- OAuth-отвязывание → `403` для админа +```tsx + + + + +``` --- -## 5. Email-код (без изменений логики) +## 6. Формы по вкладкам -`POST /api/auth/request-code` и `POST /api/auth/verify-code` работают как раньше, изменений нет. Админ входит только этим способом. +### Пароль (вход) +- Email TextField +- Пароль TextField +- Button contained fullWidth: «Войти» + +### Пароль (регистрация) +- Email TextField +- Имя TextField (опционально, helperText: «Необязательно. Будет использована часть email») +- Пароль TextField +- Подтверждение пароля TextField (с валидацией совпадения) +- Button contained fullWidth: «Зарегистрироваться» + +### Код +- Строка: Email + кнопка «Отправить код» +- Строка: поле Код + кнопка «Войти» +- Alert outlined success после успешной отправки + +### Другой способ +- OAuthButtons — стилизовать кнопки как outlined pill (borderRadius 24px, fullWidth) +- Кнопки: «Войти через Яндекс ID», «Войти через VK ID» --- -## 6. Изменения на клиенте +## 7. Alert'ы -### 6.1. Страница `/auth` - -**3 вкладки:** -- **«Пароль»** — переключатель Вход/Регистрация. Вход: email + пароль. Регистрация: email + пароль + подтверждение пароля + имя (опционально). -- **«Код»** — как сейчас: email → отправить код → ввести код. -- **«Другой способ»** — кнопки Войти через VK / Яндекс. - -### 6.2. Страница `/me` (настройки) - -Новая секция «Методы входа»: -- Список методов с индикаторами «привязан» / «не привязан» -- Кнопки «Привязать» (редирект на OAuth или форма пароля) -- Кнопки «Отвязать» (disabled если это последний метод) -- Для админа — секция скрыта - -### 6.3. Effector-стейт - -- `$token`, `$user`, `tokenSet`, `logout` — без изменений -- Добавить эффекты: `loginFx`, `registerFx`, `linkOAuthFx`, `setPasswordFx`, `unlinkOAuthFx` - -### 6.4. Компоненты - -- `UserAvatar` — убрать проверку `avatarType`, всегда использовать DiceBear (сохранённый `avatar` или генерация на лету) -- `OAuthButtons` — без изменений (URL те же) +- Все ошибки: `Alert severity="error" variant="outlined"` внутри Paper, над формой +- Успешная отправка кода: `Alert severity="success" variant="outlined"` +- OAuth-ошибки: так же внутри Paper --- -## 7. Тестирование +## 8. Иконки в TextField -### 7.1. Серверные тесты +Добавить `InputAdornment` с иконками для визуального улучшения: +- Email: `` иконка (lucide-react) +- Пароль: `` иконка -- `POST /api/auth/register` — успешная регистрация, дубликат email, слабый пароль -- `POST /api/auth/login` — успешный вход, неверный пароль, несуществующий email, превышение rate limit -- OAuth callback — создание нового пользователя с email, авто-связывание по email, ошибка при отсутствии email от провайдера -- `POST /api/me/password` — установка, повторная установка (409), админ (403) -- `GET /api/me/auth-methods` — корректный список методов -- `DELETE /api/me/oauth/{provider}` — отвязывание, последний метод (400), админ (403) -- Админ не может войти через `/login` (403) - -### 7.2. Клиентские тесты - -- Страница `/auth` — наличие трёх вкладок, переключение -- Форма регистрации — валидация пароля, подтверждение -- Форма входа — обработка ошибок -- `/me` — отображение методов, кнопки привязки/отвязки +Иконки только если это не перегружает минималистичный стиль. Решение — использовать в полях email и пароля `startAdornment`. --- -## 8. Миграция существующих пользователей +## 9. Адаптивность -1. Все пользователи с `avatarType = 'oauth'`: `avatar = null`, `avatarType = null`. Аватар перегенерируется DiceBear при следующем отображении. -2. `avatarType` колонка удаляется из БД. -3. Существующие OAuth-аккаунты работают как раньше, но при следующем входе через OAuth обновляется логика (не запрашиваем профиль). -4. `firstName`, `lastName`, `gender` у существующих OAuth-пользователей остаются в БД (не удаляем, просто больше не пополняем из OAuth). +- `min-height` вместо `height` (использовать `minHeight: calc(100vh - 64px)`) +- Paper: `mx: 2` на мобильных, `mx: auto` на десктопе +- Pill-кнопки остаются в ряд на всех разрешениях (они и так компактные) +- Отправка кода: на мобильных поля в столбец (уже есть `direction={{ xs: 'column', sm: 'row' }}`) --- -## 9. Заметки +## 10. Плавные переходы -- **bcrypt** — не установлен, нужно добавить `npm install bcrypt` в server. -- **Rate limit** — `@fastify/rate-limit` не установлен. Добавить или реализовать самодельный in-memory rate limiter (5 попыток/мин/IP). -- Все новые эндпоинты должны валидироваться через JSON Schema (как существующие). -- Пароль никогда не возвращается в ответах API и не логируется. -- Существующий `User.passwordHash` (null у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать. +- Смена вкладок: контент формы — `opacity` transition 200ms +- Смена Вход/Регистрация: поля появляются с fade-in + +--- + +## 11. Заметки + +- BearLogo уже существует (`@/shared/ui/BearLogo`) +- OAuthButtons существует (`@/features/auth-oauth`) +- Менять бизнес-логику (хуки, mutations) не нужно — только вёрстку +- Текущий AuthPage — 232 строки, нужно заменить полностью +- Все цвета брать из темы (`primary.main`, `text.secondary`, `divider`, `background.paper`) +- Для градиента использовать `useTheme` + `alpha` из MUI diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 438cf286708da727edf656171d9794758ac2fef9..2e676d5cfc333d1cc0293d64a29de6298127cd8d 100644 GIT binary patch delta 777 zcmZozAl9%zY=Si7y@@i;jQ2JsEa~TAd@(`@}?<4LNfQ z60WSh)L@v;3-&+`1Ah+xY`!UcDnJK{@NHK)$Kt_Q-zdn)Yi}qE zRKXzM$ck_zhFdU`3rr-jEU_dpF|nvv#VAR|&_E^1x5&t{$js9%A}iaVz%w@^+^8(r zze3;7wKyy!vn0zhEhE_^!pWd8ImK1fDa6M#FV7^q670*F5OUiEV0d!Dk|8i53N%FX VPmhgZ;hbLdpQR9gLR`W+0RVeV_s#$S delta 613 zcmZozAl9%zY=Si7&51J3j5jwXEa~TA5MW^7U&r6V-^qV%v!H8sB(N^Ub?dB-n=sd}3LGmFA@76X=7{L?p`XB6V%0{RB* z)Nfk_86WXaf8)>0#l^`YD9i~qxGjKrTL23zFtYf!0zDMRKRv6S#hVwX(c7LA3|LvG zuinoh3AUOC>1xvhbP#Q$!%3l{0XXcuUR=ARxL!@@be=s!y# K{%BvqIspIyBe@~~ From 9696a4dcc3dac4a55679dbaa70becbcbc91c8295 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 13:24:35 +0500 Subject: [PATCH 15/23] feat(client): redesign auth page with minimal style, BearLogo, pill buttons --- .../pages/auth/__tests__/AuthPage.test.tsx | 6 +- client/src/pages/auth/ui/AuthPage.tsx | 328 ++++++++++++------ 2 files changed, 228 insertions(+), 106 deletions(-) diff --git a/client/src/pages/auth/__tests__/AuthPage.test.tsx b/client/src/pages/auth/__tests__/AuthPage.test.tsx index 05e2f32..d686ffc 100644 --- a/client/src/pages/auth/__tests__/AuthPage.test.tsx +++ b/client/src/pages/auth/__tests__/AuthPage.test.tsx @@ -24,9 +24,9 @@ function renderPage() { describe('AuthPage', () => { it('renders three tabs', () => { renderPage() - expect(screen.getByRole('tab', { name: 'Пароль' })).toBeTruthy() - expect(screen.getByRole('tab', { name: 'Код' })).toBeTruthy() - expect(screen.getByRole('tab', { name: 'Другой способ' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Пароль' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Код' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Другой способ' })).toBeTruthy() }) it('shows login form by default on tab 0', () => { diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 99182b7..2f59857 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -1,19 +1,22 @@ import { useEffect, useState } from 'react' +import { alpha, useTheme } from '@mui/material/styles' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import Paper from '@mui/material/Paper' import Stack from '@mui/material/Stack' -import Tab from '@mui/material/Tab' -import Tabs from '@mui/material/Tabs' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' +import { Lock, Mail } from 'lucide-react' import { useForm } from 'react-hook-form' import { useNavigate, useSearchParams } from 'react-router-dom' import { OAuthButtons } from '@/features/auth-oauth' import { apiClient } from '@/shared/api/client' import { $user, tokenSet } from '@/shared/model/auth' +import { BearLogo } from '@/shared/ui/BearLogo' type AuthResponse = { token: string @@ -36,6 +39,7 @@ function getApiErrorMessage(err: unknown): string | null { } export function AuthPage() { + const theme = useTheme() const [message, setMessage] = useState(null) const [oauthError, setOauthError] = useState(null) const [tab, setTab] = useState(0) @@ -112,121 +116,239 @@ export function AuthPage() { getApiErrorMessage(requestCode.error) || getApiErrorMessage(verifyCode.error) - const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + const passwordError = + isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null return ( - - - Вход / регистрация - + + + + + - {message && ( - - {message} - - )} - {oauthError && ( - setOauthError(null)}> - {oauthError} - - )} - {errMsg && ( - - {errMsg} - - )} + + Добро пожаловать в Любимый Креатив + - setTab(v)} sx={{ mb: 3 }}> - - - - + + Войдите или зарегистрируйтесь, чтобы продолжить + - {tab === 0 && ( - - - - + + + {[ + { label: 'Пароль', idx: 0 }, + { label: 'Код', idx: 1 }, + { label: 'Другой способ', idx: 2 }, + ].map(({ label, idx }) => ( + + ))} - - - {isRegister && ( - + {(errMsg || oauthError) && ( + { + setOauthError(null) + }} + > + {errMsg || oauthError} + + )} + {message && ( + setMessage(null)}> + {message} + )} - + {tab === 0 && ( + + + + + - {isRegister && ( - + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} + )} - {isRegister ? ( - - ) : ( - + {tab === 1 && ( + + + + + ), + }, + }} + /> + + + + + + )} - - )} - {tab === 1 && ( - - - - - - - - - )} - - {tab === 2 && ( - - - - )} + {tab === 2 && ( + + + + )} + + ) } From e468625cfc2ee77c2526fae220899af4eb6ba7af Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 13:28:45 +0500 Subject: [PATCH 16/23] chore: fix type errors, move textAlign/fontWeight to sx --- client/src/app/styles/global.css | 14 +++++++++++--- client/src/pages/auth/ui/AuthPage.tsx | 9 ++++----- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/client/src/app/styles/global.css b/client/src/app/styles/global.css index e89b3b9..0962570 100644 --- a/client/src/app/styles/global.css +++ b/client/src/app/styles/global.css @@ -27,6 +27,14 @@ font-display: swap; } -:root { color-scheme: light; } -html, body, #root { min-height: 100%; } -body { margin: 0; } +:root { + color-scheme: light; +} +html, +body, +#root { + min-height: 100%; +} +body { + margin: 0; +} diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 2f59857..25c62b5 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -116,8 +116,7 @@ export function AuthPage() { getApiErrorMessage(requestCode.error) || getApiErrorMessage(verifyCode.error) - const passwordError = - isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null return ( - + Добро пожаловать в Любимый Креатив - + Войдите или зарегистрируйтесь, чтобы продолжить @@ -192,7 +191,7 @@ export function AuthPage() { {tab === 0 && ( - + )} {!m.active && m.type !== 'password' && ( - )} diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 6f4fb62be0297c9ddb0298067e54687f168d6430..c83aff765fa853729b0eea5bb0f24c39e259b017 100644 GIT binary patch delta 1613 zcmah}O>Em_7`EfjP?xq&G&(f2(Fxk2F|D;@XK_H4(_gZxNmI7aPGl=i>ZFN(VmopE zE@>~j4#XjcNvsE?-56WffV9KJc|!DrxYF)64xG4_@3YgAHK}IF@5PUw_j#Z9XLI}P z=Jwfd-uDd6etUiz#>Zd(aBl$fVi^4={S$qc{$t0-EYja4AAHidOswD~xmdbt>uOF{ zuCl>ki049~fS>2tuw0ciLzTzV1&zE!EW1cUs&&A)5Z?*&U>_3+%3)Ls*__^iJV7{! z!Dolue)b?-P|aGIVHl1r$?5f+!m5?5j(;E9KX~E!2#6$=){HC!)insZhCoG|UstqH z27z9_)e7VL^alui4qPFr_*`uEdhGMH_|&cFTJazit?N%#CC^!@VC4G zcz*_A&#&}nA^aUN0AGrFkAe?LwzC;&s47!6HY7Qx`%?m6hyL>cq-fuC z)YAp}(a>K*qfp1YLyawt92B=Ez@WGsLQNmpB9DtMb5I+@LT!p!;uXuzI$LMS+0%Ef zH$o~)B}tO3;;270$}*$c!k5Ne&_6TVn30%@bt`JdBZ*~uQIF(P_Jq8&I=>NHoFAWw zPO(-n8%gDbYABLJr5}iHr0-+O%5a(-5LbpB-Edvnz3Lw4)6Y+%d_@9*BcJg|CqJcojgw8_9?cMNLb6&`g~w@VquzFn3}4tYqG+r z=5=|dsszniG`}IN&hqPiEfdt0VuTZxkm#3VZfxxVaIydBCc&%Z-TaDf-cAClX&r)* z2HO6vE`tk%h_}t`-dv*NYY{CggO)WAsK|O*l{;6h``$tOz8Lc}>^SE{e%DL>bMHTr CK?FJg delta 1463 zcmZ`(OKcle6rC@4LY!wS*vP0MP(q8S(#-RWJswbG<0h>l64!`?G>{U;r0N?oyPc5MY47VOM>cH^qpjO4qpj?O*z z+SMfLWV*S;B~zykt20FS}f;NhN|p9R~?JMYC8$%;_z--e$rqr#1MW17{FjL7|ZRTERXrpQ{t`s$Eu9#Y(!q=mdm0o!U((OC3C zJh3pRPff3+q}ci3Y>A&reRNJtIlLQ$#+c_m#<=xQD5x_2aXDk6Ify!~;_2*dE`9`*^a5BAZ5zW{c2gIRtb z?;wd-MSKdi!dfw{)z{l|_FEuobj!Tjsm(@*viHP(WhUS0%j-m|rNC(d7~u5Kw?kt{ z$E!oGO@VcW7bfxEM>koPwF@oAx1HV@i9d^8JD)gpIz1R(^t6G#jwvW66^P`g5{1lK zM%N3loUi|Hp6#~|7~jc^QqE{eG{lV+wq2c@0`;PTtqsPv(QW(Ru%Dv_!w{=L-}BvI z89d&NWd=%WF(h~6pEecUz;6iZcJByw>ZY4?hT#b2Xiwea`NnZDyFA@zl(ZDFTj$^4gdfE diff --git a/server/src/index.js b/server/src/index.js index 19cd72e..2f1477e 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -75,6 +75,9 @@ await fastify.register(fastifyStatic, { fastify.decorate('authenticate', async function authenticate(request, reply) { try { + if (!request.headers.authorization && request.query?.token) { + request.headers.authorization = `Bearer ${request.query.token}` + } await request.jwtVerify() } catch { return reply.code(401).send({ error: 'Не авторизован' }) From ad43ff98b68ab36bb47108d0e5c7a804e2081bfd Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 14:12:29 +0500 Subject: [PATCH 22/23] feat: add password change and reset via email code --- client/src/pages/auth/ui/AuthPage.tsx | 127 +++++++++++++++++- .../src/pages/me/ui/sections/SettingsPage.tsx | 77 +++++++++++ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/routes/auth.js | 57 ++++++++ 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 36c87a6..985b653 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -44,6 +44,9 @@ export function AuthPage() { const [oauthError, setOauthError] = useState(null) const [tab, setTab] = useState(0) const [isRegister, setIsRegister] = useState(false) + const [showForgot, setShowForgot] = useState(false) + const [forgotStep, setForgotStep] = useState(0) + const [forgotEmail, setForgotEmail] = useState('') const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const user = useUnit($user) @@ -99,7 +102,7 @@ export function AuthPage() { mutationFn: async () => { await apiClient.post('auth/request-code', { email }) }, - onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'), + onSuccess: () => setMessage('Код отправлен. Проверьте почту.'), }) const verifyCode = useMutation({ @@ -110,11 +113,38 @@ export function AuthPage() { }, }) + const forgotCode = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/forgot-password', { email: forgotEmail }) + }, + onSuccess: () => { + setForgotStep(1) + setMessage('Код отправлен на почту') + }, + }) + + const resetPassword = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/reset-password', { + email: forgotEmail, + code, + newPassword: password, + }) + }, + onSuccess: () => { + setShowForgot(false) + setForgotStep(0) + setMessage('Пароль изменён. Войдите с новым паролем.') + }, + }) + const errMsg = getApiErrorMessage(loginMutation.error) || getApiErrorMessage(registerMutation.error) || getApiErrorMessage(requestCode.error) || - getApiErrorMessage(verifyCode.error) + getApiErrorMessage(verifyCode.error) || + getApiErrorMessage(forgotCode.error) || + getApiErrorMessage(resetPassword.error) const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null @@ -304,6 +334,99 @@ export function AuthPage() { Войти )} + + {!isRegister && !showForgot && ( + + )} + + {showForgot && ( + <> + setForgotEmail(e.target.value)} + fullWidth + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + {forgotStep === 1 && ( + <> + + { + register('code').onChange(e) + }} + sx={{ flex: 1 }} + /> + + + + + + )} + + {forgotStep === 0 && ( + + )} + + + + )} )} diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 943f542..4b62caa 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -30,6 +30,7 @@ import { type AuthMethod, } from '@/shared/model/auth' import { UserAvatar } from '@/shared/ui/UserAvatar' +import { apiClient } from '@/shared/api/client' import type { AxiosError } from 'axios' function getApiErrorMessage(error: unknown): string | null { @@ -101,6 +102,21 @@ export function SettingsPage() { onError: () => {}, }) + const [showChangePassword, setShowChangePassword] = useState(false) + const changePasswordForm = useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>({ + defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' }, + }) + + const changePasswordMutation = useMutation({ + mutationFn: async (params: { oldPassword: string; newPassword: string }) => { + await apiClient.post('me/change-password', params) + }, + onSuccess: () => { + setShowChangePassword(false) + changePasswordForm.reset() + }, + }) + const linkedCount = useCallback(() => { return authMethods.filter((m) => m.active).length }, [authMethods]) @@ -271,6 +287,11 @@ export function SettingsPage() { Отвязать )} + {m.active && m.type === 'password' && ( + + )} {!m.active && m.type === 'password' && ( + + + + )} )} diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index c83aff765fa853729b0eea5bb0f24c39e259b017..cda19f98ac667ce705f33772b47bee629bf290d8 100644 GIT binary patch delta 2032 zcmaJ?T}&KR6rS0c5h(25C59RlN^Oy7D((F5?1P!LNC^#HNL!@Fre$V#_J3z)|7KWT zfER0HNNb4MM3a^$jrt;PU43axOq(XArue{%FKXMQF~nF5`p^gQ-dVPQ1J1+QY|eMj z`ObIGJ!f`t|Lo%atIvDD%KKf%p=lho5&FhLZ#kT#`c&5VYS)XT(p9houW zNqq;9-466%7}gipiv4D{Qr?1zbWqw3BfnfE{XWVoU?Ata?>pnYHfo>D z%z2-qC<=>Y&A6ebg(ywaQp%cUqhc;*Tj^BRRML__God&t4>1&2QBgEO3q~VBfsL>{ ztMXA^ROzV3X@bfqf*6dlfgl4ZXJ8(fu%-)OVg8RUoPySF zqrmqV_#3Q(U%<-O{x9&NuR3|~1m3!jpm3NE=BBDE8>@22RJn2tR?QWRNH(rBT9VD% z5V5{?*K?SZsTL#+1WgAZNJOAQG0hv&blCwts)g|T-~d8*l1HePvBB^g!{MQeW3Rt; zYA}3u6ElXln)vN%d*~xjxpti$9})6hxtajO)~t7b$~Q+nlt4bQ8x zsq!=nT?Puvnc0XXlr<~OupHv~CsDV161YLEf{tBd)cnLM%nVg>v95&(`dj2Gr(k9Q z3CM}%w2>G1$~PNC-zg}aqte~Yyzbk`;ePo@OE=cp+)O-+VH71dD5@}|ghB>HtIW{_ z(+PQKeeF?Q_y*2(cQ?o%0Jkft?J7^Z;@AA-Pd$C`pkcw(F(#%P$#_Bx*!d}_A}^q{ zkIlN>lxr7gEkw4`c*^P7N@|8|pFz@Rc*S0=Yoj%PWVyI6oHm>GNdUQvhx@d1# z(9OgP=N9J2FyCL`bKg~85Q^|C97YE{UU{Me`FiHyKyvshwuvr4M5&8$+cs3za)xLD zjXk@cNmEIxt#Paqof;+EJ>(N{NBo;7~YmZeqlY4yA>$s5&-KK6xT}PGO{xi6MvC zyLAhBogGE&;P+HU*91IPcJ|Nztsf`cgS!2&woJAhY`P6DgHOO^c<*o5hhZhZLH0N= z!1YW?@Bllo=QD5?!k(Rt7G(FPH^;y}qS2rz=K{P|;w)sxU-_xWSN;VC CM4A`? delta 1808 zcmZ`(K}_3L7`9`3C4=MzbyVs`$Fxx<)D`U5AQy zO@T|L9;$9aQx26@PGj9=TSKE=s$HmdsJ5{K)3nnD+&FZX?aJPB!j|IHhyURJd*A>6 z-}~Obws~%C^V}z|I^3h5c3*+<+m9aHJ@Xs*2HXO_Ry*k_u(5or0b z=D^-27MvENnv&zmCZt99GOXHQe7H$^I!ME|YB=dyl)SU;MlRkAXWFI0Qd{ufQFc{}^nfpLK6Mcyc*=mMpD-j zc_wme7q9I))&{#YJ!TBSEFaHHT+;7TWj&@=K09{2Bfv58Xx3CwQxm2X=FO<;%PWkR zwFX|U$Hu^UG_Zn@ygsDs{aZc)Aouca=0 zgVKI?+vJacZP`?_%69&mhy1;#4<1ZMVCiTf9TTN^(i`Tv5>%1%p|bmWJ)X#Aht`5* zJB>er{#*f-M49X?!wb)vHwi)=*zfJ`GzwH6fdZd|E2;oN7i2%|5la-K?O!6Bh{PK7>!50GKaU|J+P>`+4y?t^D#6`C57Z zVBTV#pLcW!>n!dP7T!H2MvI64;+1+zjuvb8IYCCDBB7auh0I)`bk(;sIC+Jg93C#L zYLddL`h+x6Q2csvIuYZSM!8k57WF5U>^RHMSRT4qkF{OW`37|W*Yj4@vvvbiK3pc9 zmCP~OQ%0M^c9rDG%0rLQwuk>S8q8+VW2ez$_xVgInOCLfjAnB-RPNeEFU<_FR^%Re IsK4(13ju0b_5c6? diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 4b2a2e5..5311917 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -138,6 +138,39 @@ export async function registerAuthRoutes(fastify) { return { token, user: mapUserForClient(user) } }) + fastify.post('/api/auth/forgot-password', async (request) => { + const email = normalizeEmail(request.body?.email) + if (!email || !email.includes('@')) return { ok: true } + + if (isAdminEmail(email)) return { ok: true } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !user.passwordHash) return { ok: true } + + await issueEmailCode({ email, purpose: 'reset_password' }) + return { ok: true } + }) + + fastify.post('/api/auth/reset-password', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const code = String(request.body?.code || '').trim() + const newPassword = String(request.body?.newPassword || '') + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) + + const ok = await verifyEmailCode({ email, purpose: 'reset_password', code }) + if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) + + const passwordErr = validatePassword(newPassword) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(newPassword) + await prisma.user.update({ where: { email }, data: { passwordHash } }) + + return { ok: true } + }) + fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => { const userId = request.user.sub const user = await prisma.user.findUnique({ where: { id: userId } }) @@ -183,6 +216,30 @@ export async function registerAuthRoutes(fastify) { return { ok: true } }) + fastify.post('/api/me/change-password', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может менять пароль' }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + if (!user.passwordHash) return reply.code(400).send({ error: 'Пароль не установлен. Используйте установку пароля.' }) + + const oldPassword = String(request.body?.oldPassword || '') + const valid = await comparePassword(oldPassword, user.passwordHash) + if (!valid) return reply.code(401).send({ error: 'Неверный текущий пароль' }) + + const newPassword = String(request.body?.newPassword || '') + const passwordErr = validatePassword(newPassword) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(newPassword) + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) + + return { ok: true } + }) + fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const provider = request.params?.provider From d79d02d5d1975c8503ec60d002b1736dd69c7df0 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 14:20:11 +0500 Subject: [PATCH 23/23] refactor: remove email change functionality --- .../src/pages/me/ui/sections/SettingsPage.tsx | 52 ------------------ client/src/shared/model/auth.ts | 17 +----- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/routes/auth.js | 44 --------------- 4 files changed, 2 insertions(+), 111 deletions(-) diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 4b62caa..df6635a 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -17,16 +17,12 @@ import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { - $requestEmailChangeCodeError, $updateProfileError, $user, - $verifyEmailChangeError, fetchAuthMethodsFx, - requestEmailChangeCodeFx, setPasswordFx, unlinkOAuthFx, updateProfileFx, - verifyEmailChangeFx, type AuthMethod, } from '@/shared/model/auth' import { UserAvatar } from '@/shared/ui/UserAvatar' @@ -41,17 +37,8 @@ function getApiErrorMessage(error: unknown): string | null { export function SettingsPage() { const user = useUnit($user) - const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) - const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) const pendingProfile = useUnit(updateProfileFx.pending) - const errorEmailReq = useUnit($requestEmailChangeCodeError) const errorProfile = useUnit($updateProfileError) - const errorEmailVerify = useUnit($verifyEmailChangeError) - - const emailForm = useForm<{ newEmail: string; code: string }>({ - defaultValues: { newEmail: '', code: '' }, - mode: 'onChange', - }) const profileForm = useForm<{ displayName: string }>({ defaultValues: { @@ -60,7 +47,6 @@ export function SettingsPage() { mode: 'onChange', }) - const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) @@ -136,11 +122,6 @@ export function SettingsPage() { Текущая почта: {user.email} - {emailErrorMsg && ( - - {emailErrorMsg} - - )} {profileErrorMsg && ( {profileErrorMsg} @@ -408,39 +389,6 @@ export function SettingsPage() { )} - - - - - - Смена почты - - - - - - - - - - ) diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 36cf4d6..cf018fc 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -60,17 +60,6 @@ sample({ target: $user, }) -// ----- Email change ----- - -export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => { - await apiClient.post('me/change-email/request-code', { newEmail }) -}) - -export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => { - const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params) - return data.user -}) - // ----- Profile update ----- export type UpdateProfileParams = { @@ -113,17 +102,15 @@ export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => { // ----- Error stores ----- -export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error -export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error export const $updateProfileError = createErrorStore(updateProfileFx).$error // ----- Re-exports ----- export { readStoredToken } from '@/shared/lib/persist-token' -// ----- Sync user from profile/email changes ----- +// ----- Sync user from profile changes ----- sample({ - clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData], + clock: [updateProfileFx.doneData], target: $user, }) diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index cda19f98ac667ce705f33772b47bee629bf290d8..f9777dd0595fe3c573bdd7775c645219a532f128 100644 GIT binary patch delta 881 zcmZozAl9%zY=Si7t%)+ujJGx>Ea~TA;=jzmf0_Rke>;EsWd@(%bCTP4JS{0 zFD%X^%52D)TacJ-VQOILP??yQl3JlxRNAy)+X6-w1rA33#|-?B`HyZEbePRQJwAqo zefqldjCR7whACwR1_tKoWu+-qIf=Oj$*JxC&ogfSf1Zg|frXL3fMK)1g9QGG0UXl{ z{xb>I1Jzdn)mwtpn;EATnU|)dLo72kvM?|)G_f$NG>DHcPj_cLN%MBL4EIYk&96$z2`sNjsw&R$(@kca=l69sJA_QmEl4lG;nUiT?np~Wd zR+&`-bo_S_9|#F^DK>T699UQkfKjrQfqyH17BDX3_@`&pv&32^8>Sio4NU@tZl+~M zS*}@bBF?Y?2Nf5D+|~dw5i^qav)HL%GnN+^7EJu94E(G3Q~4(FSpr@0kazpXvn(Zy z_0CK@_J%-t5I}f07w9qruy-vCOHEA;Q{kyCJvT8kM=v=)7wmO62pPf%G(;9;ggnd; zY%vNq1fFDbWuU1zu`ID9F)^{I80<$D2)XS73yT6UFB}CYXa42*+)Xy$o85e8y(D;v zJ&2JW78Epxf(JpUrO8n|^eFA6e?TpG5D!+=Yn)_54;pZWd8gm+y~mr^>DxPfd+!Q< z^!#cNKiYY?{p=Dpk6~~J-7f6zTFD69iob5UisE8YmJAq%UC)R$m%^(q%Iozp3`Nl_ z>Ei-oisc4AZG=hrWGmK@Tg*=K)GrB)#}e5Mjf?Y8!RB- zU!i^W#$etE97E8@zd&uP4eVw!N@HU7_nsC147G1jquFc%R-0);K8P8?;SdB2AK?uj ze$nqi&t$r6LWdx9U$opnzS2~>BK<+9XVK10Q?UGkZ!JBG z54kv=8=igwHGEBQQl#@v!6)90O1HBap7(eP!DOhqT&`z> { - const userId = request.user.sub - const newEmail = normalizeEmail(request.body?.newEmail) - if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - - const exists = await prisma.user.findUnique({ - where: { email: newEmail }, - }) - if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) - - await issueEmailCode({ - email: newEmail, - purpose: 'change_email', - userId, - }) - return { ok: true } - }) - - fastify.post('/api/me/change-email/verify', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const newEmail = normalizeEmail(request.body?.newEmail) - const code = String(request.body?.code || '').trim() - if (!newEmail || !newEmail.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) - - const exists = await prisma.user.findUnique({ - where: { email: newEmail }, - }) - if (exists) return reply.code(409).send({ error: 'Эта почта уже занята' }) - - const ok = await verifyEmailCode({ - email: newEmail, - purpose: 'change_email', - code, - userId, - }) - if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) - - const user = await prisma.user.update({ - where: { id: userId }, - data: { email: newEmail }, - }) - return { user: mapUserForClient(user) } - }) fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub