# 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.