1776 lines
54 KiB
Markdown
1776 lines
54 KiB
Markdown
# 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.
|