Files
shop-server/docs/superpowers/plans/2026-05-22-auth-redesign.md
T

1776 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: <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:
```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<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**
```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<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 (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 `<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**
```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<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):
```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 && (
- <Button variant="outlined" ...>Использовать OAuth</Button>
- )}
```
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<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`:
```tsx
{!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:
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 `<UserAvatar>` 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(
<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**
```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.