54 KiB
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
cd server && npm install bcrypt
- Step 2: Verify install
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?.
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:
-- Before ALTER TABLE DROP COLUMN:
UPDATE User SET avatar = NULL WHERE avatarType = 'oauth';
- Step 3: Run migration
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
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
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
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
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: <some positive number>
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:
import { hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js'
Add route inside registerAuthRoutes, after the verify-code route and before /api/me:
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:
import { comparePassword, isAdminEmail } from '../lib/auth.js'
import { checkLoginRateLimit } from '../lib/rate-limit.js'
Add route inside registerAuthRoutes, after register route:
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:
avatar: user.avatar,
- avatarType: user.avatarType,
avatarStyle: user.avatarStyle,
Edit the profile PATCH route in same file, remove all avatarType handling:
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:
- const avatarTypeRaw = request.body?.avatarType
- const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
const avatarStyleRaw = request.body?.avatarStyle
And in data construction:
- 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):
avatar: user.avatar,
- avatarType: user.avatarType,
avatarStyle: user.avatarStyle,
Remove avatarType from GET /api/admin/avatar response (line 28):
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
avatarTypefrom data object (lines 60-62) - Remove
avatarTypefrom 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:
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:
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):
- 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:
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:
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:
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:
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:
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:
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:
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:
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
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
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
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
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
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
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<string | null>()
export const logout = createEvent()
// ----- Token persistence -----
const persistTokenFx = createEffect<string | null, void>({
handler: (token) => persistToken(token),
})
export const $token = createStore<string | null>(null)
.on(tokenSet, (_, t) => t)
.reset(logout)
sample({ clock: $token, target: persistTokenFx })
// ----- User -----
export const $user = createStore<AuthUser | null>(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
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<Theme>
}
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 (
<Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
?
</Avatar>
)
}
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 (removeavatarTypeprop)client/src/pages/admin-settings/ui/AdminSettingsPage.tsx— lines 147, 164 (removeavatarTypeprop)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 <UserAvatar> 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
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<string, unknown>
const response = anyErr.response as Record<string, unknown> | undefined
const data = response?.data as Record<string, unknown> | undefined
const msg = data?.error
return typeof msg === 'string' ? msg : null
}
export function AuthPage() {
const [message, setMessage] = useState<string | null>(null)
const [oauthError, setOauthError] = useState<string | null>(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<AuthResponse>('auth/login', { email, password })
tokenSet(data.token)
navigate('/', { replace: true })
},
})
const registerMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('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<AuthResponse>('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 (
<Box>
<Typography variant="h4" gutterBottom>
Вход / регистрация
</Typography>
{message && <Alert severity="success" sx={{ mb: 2 }}>{message}</Alert>}
{oauthError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>{oauthError}</Alert>
)}
{errMsg && <Alert severity="error" sx={{ mb: 2 }}>{errMsg}</Alert>}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
<Tab label="Пароль" />
<Tab label="Код" />
<Tab label="Другой способ" />
</Tabs>
{tab === 0 && (
<Stack spacing={2} sx={{ maxWidth: 520 }}>
<Stack direction="row" spacing={1}>
<Button variant={!isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(false)}>
Вход
</Button>
<Button variant={isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(true)}>
Регистрация
</Button>
</Stack>
<TextField label="Email" {...register('email')} fullWidth />
{isRegister && (
<TextField
label="Имя (необязательно)"
{...register('displayName')}
fullWidth
helperText="Если не указать, будет использована часть email до @"
/>
)}
<TextField label="Пароль" type="password" {...register('password')} fullWidth />
{isRegister && (
<TextField
label="Подтверждение пароля"
type="password"
{...register('passwordConfirm')}
fullWidth
error={Boolean(passwordError)}
helperText={passwordError}
/>
)}
{isRegister ? (
<Button
variant="contained"
disabled={
!email ||
!password ||
password.length < 8 ||
(isRegister && password !== passwordConfirm) ||
registerMutation.isPending
}
onClick={() => registerMutation.mutate()}
>
Зарегистрироваться
</Button>
) : (
<Button
variant="contained"
disabled={!email || !password || loginMutation.isPending}
onClick={() => loginMutation.mutate()}
>
Войти
</Button>
)}
</Stack>
)}
{tab === 1 && (
<Stack spacing={2} sx={{ maxWidth: 520 }}>
<TextField label="Email" {...register('email')} fullWidth />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button
variant="outlined"
onClick={() => requestCode.mutate()}
disabled={!email || requestCode.isPending}
>
Отправить код
</Button>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} />
<Button
variant="contained"
onClick={() => verifyCode.mutate()}
disabled={!email || code.length !== 6 || verifyCode.isPending}
>
Войти
</Button>
</Stack>
</Stack>
)}
{tab === 2 && (
<Stack sx={{ maxWidth: 520 }}>
<OAuthButtons />
</Stack>
)}
</Box>
)
}
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):
const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated')
const useOAuth = user?.avatarType === 'oauth'
const useGenerated = user?.avatarType === 'generated'
With:
// no more avatarType — always internal avatars
And replace the caption (lines 140):
- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
+ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
Remove the "Use OAuth" button block entirely (lines 208-222):
- {hasOAuthAvatar && !hasUnsavedPreview && (
- <Button variant="outlined" ...>Использовать OAuth</Button>
- )}
And in UserAvatar usage, remove avatarType prop (lines 131, 148):
- avatarType={hasUnsavedPreview ? 'generated' : user.avatarType}
(just delete that prop line)
Remove avatarType from updateProfileFx calls (lines 191-197 and any other):
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:
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:
const [authMethods, setAuthMethods] = useState<AuthMethod[]>([])
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<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
- Step 3: Add auth methods section UI
Insert after the avatar section's closing </Box> + <Divider /> (before email change section), but only if !user.isAdmin:
{!user.isAdmin && (
<>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Методы входа
</Typography>
<Stack spacing={1}>
{authMethods.map((m) => (
<Stack key={m.type} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Typography sx={{ minWidth: 120 }}>{METHOD_LABELS[m.type] || m.type}</Typography>
<Chip
label={m.active ? 'Привязан' : 'Не привязан'}
color={m.active ? 'success' : 'default'}
size="small"
/>
{m.active && m.type !== 'password' && (
<Button
size="small"
variant="outlined"
color="error"
disabled={linkedCount() <= 1}
onClick={() => unlinkMutation.mutate(m.type as 'vk' | 'yandex')}
>
Отвязать
</Button>
)}
{!m.active && m.type === 'password' && (
<Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
Установить пароль
</Button>
)}
{!m.active && m.type !== 'password' && (
<Button
size="small"
variant="outlined"
component="a"
href={`/api/auth/oauth/${m.type}/link`}
>
Привязать
</Button>
)}
</Stack>
))}
</Stack>
{showSetPassword && (
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<TextField label="Пароль" type="password" {...passwordForm.register('password')} fullWidth />
<TextField
label="Подтверждение пароля"
type="password"
{...passwordForm.register('passwordConfirm')}
fullWidth
error={
Boolean(passwordForm.watch('passwordConfirm')) &&
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
}
helperText={
passwordForm.watch('passwordConfirm') &&
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
? 'Пароли не совпадают'
: null
}
/>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
disabled={
!passwordForm.watch('password') ||
passwordForm.watch('password').length < 8 ||
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm') ||
setPasswordMutation.isPending
}
onClick={() => setPasswordMutation.mutate(passwordForm.getValues('password'))}
>
Сохранить
</Button>
<Button variant="text" onClick={() => setShowSetPassword(false)}>
Отмена
</Button>
</Stack>
</Stack>
)}
</Box>
</>
)}
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:
- Remove state lines (find the equivalent of
hasOAuthAvatar,useOAuth,useGenerated):
- const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated')
- const useOAuth = user?.avatarType === 'oauth'
- const useGenerated = user?.avatarType === 'generated'
- Remap the caption line:
- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
+ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
-
Remove the "Use OAuth" button block (find lines starting with
{hasOAuthAvatar && !hasUnsavedPreview). -
Remove
avatarTypeprop from all<UserAvatar>usages in this file. -
Remove
avatarTypefrom anyupdateProfileFxor admin profile API calls in this file. Find the PATCH call payload and remove theavatarTypefield.
Task 20: Client tests
Files:
-
Create:
client/src/pages/auth/__tests__/AuthPage.test.tsx -
Step 1: Write AuthPage tests
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(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AuthPage />
</MemoryRouter>
</QueryClientProvider>,
)
}
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
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
cd server && npx vitest run
- Step 2: Run client tests
cd client && npx vitest run
- Step 3: Run client lint + format check
cd client && npm run lint && npm run format:check
- Step 4: Run client build
cd client && npm run build
All must pass.