diff --git a/.superpowers/brainstorm/12055-1779436874/state/server.pid b/.superpowers/brainstorm/12055-1779436874/state/server.pid new file mode 100644 index 0000000..ad52c0c --- /dev/null +++ b/.superpowers/brainstorm/12055-1779436874/state/server.pid @@ -0,0 +1 @@ +12063 diff --git a/.superpowers/brainstorm/12189-1779436893/state/server.pid b/.superpowers/brainstorm/12189-1779436893/state/server.pid new file mode 100644 index 0000000..8be8535 --- /dev/null +++ b/.superpowers/brainstorm/12189-1779436893/state/server.pid @@ -0,0 +1 @@ +12189 diff --git a/.superpowers/brainstorm/12680-1779437109/state/server.pid b/.superpowers/brainstorm/12680-1779437109/state/server.pid new file mode 100644 index 0000000..32e8255 --- /dev/null +++ b/.superpowers/brainstorm/12680-1779437109/state/server.pid @@ -0,0 +1 @@ +12688 diff --git a/.superpowers/brainstorm/12844-1779437126/state/server.pid b/.superpowers/brainstorm/12844-1779437126/state/server.pid new file mode 100644 index 0000000..aa93a35 --- /dev/null +++ b/.superpowers/brainstorm/12844-1779437126/state/server.pid @@ -0,0 +1 @@ +12844 diff --git a/.superpowers/brainstorm/12988-1779437168/state/server.pid b/.superpowers/brainstorm/12988-1779437168/state/server.pid new file mode 100644 index 0000000..204a445 --- /dev/null +++ b/.superpowers/brainstorm/12988-1779437168/state/server.pid @@ -0,0 +1 @@ +12996 diff --git a/.superpowers/brainstorm/13143-1779437184/state/server.pid b/.superpowers/brainstorm/13143-1779437184/state/server.pid new file mode 100644 index 0000000..5030d63 --- /dev/null +++ b/.superpowers/brainstorm/13143-1779437184/state/server.pid @@ -0,0 +1 @@ +13143 diff --git a/client/public/fonts/Outfit-Bold.woff2 b/client/public/fonts/Outfit-Bold.woff2 new file mode 100644 index 0000000..8674e73 Binary files /dev/null and b/client/public/fonts/Outfit-Bold.woff2 differ diff --git a/client/public/fonts/Outfit-Medium.woff2 b/client/public/fonts/Outfit-Medium.woff2 new file mode 100644 index 0000000..264ce74 Binary files /dev/null and b/client/public/fonts/Outfit-Medium.woff2 differ diff --git a/client/public/fonts/Outfit-Regular.woff2 b/client/public/fonts/Outfit-Regular.woff2 new file mode 100644 index 0000000..2c401f5 Binary files /dev/null and b/client/public/fonts/Outfit-Regular.woff2 differ diff --git a/client/public/fonts/Outfit-SemiBold.woff2 b/client/public/fonts/Outfit-SemiBold.woff2 new file mode 100644 index 0000000..aa32da2 Binary files /dev/null and b/client/public/fonts/Outfit-SemiBold.woff2 differ diff --git a/client/src/app/styles/global.css b/client/src/app/styles/global.css index 0cf9d1c..e89b3b9 100644 --- a/client/src/app/styles/global.css +++ b/client/src/app/styles/global.css @@ -1,13 +1,32 @@ -:root { - color-scheme: light; +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); + font-display: swap; } -html, -body, -#root { - min-height: 100%; -} - -body { - margin: 0; -} +:root { color-scheme: light; } +html, body, #root { min-height: 100%; } +body { margin: 0; } diff --git a/docs/superpowers/plans/2026-05-22-auth-redesign.md b/docs/superpowers/plans/2026-05-22-auth-redesign.md index 4e2cf6f..0b2ffca 100644 --- a/docs/superpowers/plans/2026-05-22-auth-redesign.md +++ b/docs/superpowers/plans/2026-05-22-auth-redesign.md @@ -1,1229 +1,154 @@ -# Auth Redesign — Implementation Plan +# Auth Page Redesign — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Переработать аутентификацию: OAuth только email, внутренние аватары, вход по email+паролю, связывание методов входа в ЛК. +**Goal:** Минималистичный редизайн страницы входа: Outfit локально, BearLogo, Paper-карточка, pill-кнопки, радиальный градиент. -**Architecture:** Server: Fastify + Prisma + bcrypt + in-memory rate limiter. Client: React + Effector + MUI tabs. Email остаётся единым идентификатором. +**Architecture:** MUI sx prop only, замена `client/src/pages/auth/ui/AuthPage.tsx` полностью. Шрифт в `client/public/fonts/` + `@font-face` в `global.css`. -**Tech Stack:** Node.js, Fastify, Prisma, bcrypt, React, MUI, Effector, effector-react +**Tech Stack:** React, MUI, lucide-react (иконки) --- -### Task 1: Install dependencies +### Task 1: Download Outfit font and add @font-face **Files:** -- Modify: `server/package.json` +- Create: `client/public/fonts/Outfit-Regular.woff2` +- Create: `client/public/fonts/Outfit-Medium.woff2` +- Create: `client/public/fonts/Outfit-SemiBold.woff2` +- Create: `client/public/fonts/Outfit-Bold.woff2` +- Modify: `client/src/app/styles/global.css` -- [ ] **Step 1: Install bcrypt** +- [ ] **Step 1: Create fonts directory** ```bash -cd server && npm install bcrypt +mkdir -p /mnt/d/my_projects/shop/client/public/fonts ``` -- [ ] **Step 2: Verify install** +- [ ] **Step 2: Download Outfit woff2 files** ```bash -cd server && node -e "require('bcrypt')" +cd /mnt/d/my_projects/shop/client/public/fonts +curl -sL 'https://fonts.google.com/download?family=Outfit' -o outfit.zip +# OR download individual woff2 files from a CDN: +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-400-normal.woff2' -o Outfit-Regular.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-500-normal.woff2' -o Outfit-Medium.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-600-normal.woff2' -o Outfit-SemiBold.woff2 +curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-700-normal.woff2' -o Outfit-Bold.woff2 ``` -Expected: no error. - ---- - -### Task 2: Prisma schema — remove avatarType - -**Files:** -- Modify: `server/prisma/schema.prisma` - -- [ ] **Step 1: Remove avatarType from User model** - -Edit `server/prisma/schema.prisma`: remove line `avatarType String?`. - -```diff - avatar String? -- avatarType String? - avatarStyle String? -``` - -- [ ] **Step 2: Add data migration comments to migration** - -The migration SQL must also clean up existing OAuth avatar URLs. After Prisma generates the migration, edit the SQL file to include: - -```sql --- Before ALTER TABLE DROP COLUMN: -UPDATE User SET avatar = NULL WHERE avatarType = 'oauth'; -``` - -- [ ] **Step 3: Run migration** +Wait — the jsdelivr URLs may not be exact. Better approach: use `@fontsource/outfit` npm package or download from fontsource CDN: ```bash -cd server && npx prisma migrate dev --name remove_avatarType +cd /mnt/d/my_projects/shop/client/public/fonts +# Outfit Regular (400) +curl -sLo Outfit-Regular.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-400-normal.woff2' +# Outfit Medium (500) +curl -sLo Outfit-Medium.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-500-normal.woff2' +# Outfit SemiBold (600) +curl -sLo Outfit-SemiBold.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-600-normal.woff2' +# Outfit Bold (700) +curl -sLo Outfit-Bold.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-700-normal.woff2' ``` -Check the generated migration SQL in `server/prisma/migrations/`. Edit it if Prisma didn't include the cleanup SQL. - -Expected: migration runs successfully. - -- [ ] **Step 4: Verify Prisma client regenerated** +- [ ] **Step 3: Verify files downloaded** ```bash -cd server && node -e "const {prisma} = require('./src/lib/prisma.js'); prisma.user.findFirst().then(u => { console.log('avatarType' in (u||{})); process.exit(0) })" +ls -la /mnt/d/my_projects/shop/client/public/fonts/ ``` -Expected: `false` (avatarType not in prisma client). +Expected: 4 woff2 files, each > 10KB. ---- +- [ ] **Step 4: Add @font-face to global.css** -### Task 3: Add password validation and bcrypt helpers to lib/auth.js +Read `/mnt/d/my_projects/shop/client/src/app/styles/global.css`. It currently has: -**Files:** -- Modify: `server/src/lib/auth.js` - -- [ ] **Step 1: Add imports and helpers** - -```js -import bcrypt from 'bcrypt' - -const PASSWORD_MIN_LEN = 8 - -const PASSWORD_REGEX = { - letter: /[a-zа-яё]/i, - digit: /[0-9]/, - special: /[^a-zа-яё0-9\s]/i, -} - -export function validatePassword(password) { - if (typeof password !== 'string') return 'Пароль обязателен' - if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов` - if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву' - if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру' - if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол' - return null -} - -export async function hashPassword(password) { - return bcrypt.hash(password, 10) -} - -export async function comparePassword(password, hash) { - return bcrypt.compare(password, hash) -} - -export function isAdminEmail(email) { - const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() - if (!adminEmail) return false - return normalizeEmail(email) === adminEmail -} +```css +:root { color-scheme: light; } +html, body, #root { min-height: 100%; } +body { margin: 0; } ``` ---- +Replace entire file with: -### Task 4: Add in-memory rate limiter for login - -**Files:** -- Create: `server/src/lib/rate-limit.js` - -- [ ] **Step 1: Create rate limiter** - -```js -const windows = new Map() - -const MAX_ATTEMPTS = 5 -const WINDOW_MS = 60_000 - -export function checkLoginRateLimit(ip) { - const now = Date.now() - const entry = windows.get(ip) - if (!entry || now - entry.start > WINDOW_MS) { - windows.set(ip, { start: now, count: 1 }) - return { allowed: true } - } - entry.count += 1 - if (entry.count > MAX_ATTEMPTS) { - const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) - return { allowed: false, retryAfter } - } - return { allowed: true } +```css +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); + font-display: swap; } +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); + font-display: swap; +} + +:root { color-scheme: light; } +html, body, #root { min-height: 100%; } +body { margin: 0; } ``` -- [ ] **Step 2: Test rate limiter** +- [ ] **Step 5: Commit** ```bash -cd server && node -e " -const { checkLoginRateLimit } = require('./src/lib/rate-limit.js'); -checkLoginRateLimit('test1'); checkLoginRateLimit('test1'); -checkLoginRateLimit('test1'); checkLoginRateLimit('test1'); -const r = checkLoginRateLimit('test1'); console.log('5th allowed:', r.allowed); -const r2 = checkLoginRateLimit('test1'); console.log('6th blocked:', !r2.allowed, 'retryAfter:', r2.retryAfter > 0); -" +cd /mnt/d/my_projects/shop +git add client/public/fonts/ client/src/app/styles/global.css +git commit -m "feat: load Outfit font from static files" ``` -Expected: `5th allowed: true`, `6th blocked: true retryAfter: ` - --- -### Task 5: Add register endpoint +### Task 2: Rewrite AuthPage with new design **Files:** -- Modify: `server/src/routes/auth.js` +- Modify: `client/src/pages/auth/ui/AuthPage.tsx` (replace entirely) -- [ ] **Step 1: Add POST /api/auth/register** +- [ ] **Step 1: Read the current file for reference** -Add after existing imports, before `export async function registerAuthRoutes`: +Read `/mnt/d/my_projects/shop/client/src/pages/auth/ui/AuthPage.tsx` — keep the imports, hooks and mutation logic. Only the render JSX changes. -Add import: -```js -import { hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js' -``` +- [ ] **Step 2: Replace AuthPage.tsx** -Add route inside `registerAuthRoutes`, after the `verify-code` route and before `/api/me`: - -```js -fastify.post('/api/auth/register', async (request, reply) => { - const email = normalizeEmail(request.body?.email) - const password = String(request.body?.password || '') - const displayNameRaw = request.body?.displayName - const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0] - - if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' }) - - const passwordErr = validatePassword(password) - if (passwordErr) return reply.code(400).send({ error: passwordErr }) - - const exists = await prisma.user.findUnique({ where: { email } }) - if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) - - const passwordHash = await hashPassword(password) - const user = await prisma.user.create({ - data: { - email, - passwordHash, - displayName: displayName || null, - avatar: null, - avatarStyle: 'avataaars', - }, - }) - - await prisma.notificationPreference.upsert({ - where: { userId: user.id }, - create: { userId: user.id, globalEnabled: true }, - update: {}, - }) - - const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return reply.code(201).send({ token, user: mapUserForClient(user) }) -}) -``` - ---- - -### Task 6: Add login endpoint - -**Files:** -- Modify: `server/src/routes/auth.js` -- Create: `server/src/lib/rate-limit.js` - -- [ ] **Step 1: Add POST /api/auth/login** - -Add import at top of auth.js: -```js -import { comparePassword, isAdminEmail } from '../lib/auth.js' -import { checkLoginRateLimit } from '../lib/rate-limit.js' -``` - -Add route inside `registerAuthRoutes`, after register route: - -```js -fastify.post('/api/auth/login', async (request, reply) => { - const email = normalizeEmail(request.body?.email) - const password = String(request.body?.password || '') - const ip = request.ip - - if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' }) - - const rate = checkLoginRateLimit(ip) - if (!rate.allowed) { - return reply - .code(429) - .header('Retry-After', String(rate.retryAfter)) - .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) - } - - const user = await prisma.user.findUnique({ where: { email } }) - if (!user || !user.passwordHash) { - return reply.code(401).send({ error: 'Неверная почта или пароль' }) - } - - const valid = await comparePassword(password, user.passwordHash) - if (!valid) { - return reply.code(401).send({ error: 'Неверная почта или пароль' }) - } - - const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return { token, user: mapUserForClient(user) } -}) -``` - ---- - -### Task 7: Remove avatarType from mapUserForClient and profile routes - -**Files:** -- Modify: `server/src/routes/auth.js` -- Modify: `server/src/routes/api/admin-profile.js` - -- [ ] **Step 1: Remove avatarType from mapUserForClient** - -Edit `mapUserForClient` in `server/src/routes/auth.js`: - -```diff - avatar: user.avatar, -- avatarType: user.avatarType, - avatarStyle: user.avatarStyle, -``` - -Edit the profile PATCH route in same file, remove all `avatarType` handling: - -```diff - const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() - -- if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { -- return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) -- } - if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) -``` - -Also remove `avatarType` from body destructuring and data object: - -```diff -- const avatarTypeRaw = request.body?.avatarType -- const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() - const avatarStyleRaw = request.body?.avatarStyle -``` - -And in data construction: -```diff -- if (avatarType !== undefined) { -- data.avatarType = avatarType === '' ? null : avatarType -- } -``` - -- [ ] **Step 2: Remove avatarType from admin-profile routes** - -Edit `server/src/routes/api/admin-profile.js`: - -Remove `avatarType` from GET `/api/admin/profile` response (line 14): -```diff - avatar: user.avatar, -- avatarType: user.avatarType, - avatarStyle: user.avatarStyle, -``` - -Remove `avatarType` from GET `/api/admin/avatar` response (line 28): -```diff - avatar: user.avatar, -- avatarType: user.avatarType, - avatarStyle: user.avatarStyle, -``` - -Remove `avatarType` handling from PATCH `/api/admin/profile`: -- Remove destructuring lines for `avatarTypeRaw`/`avatarType` (lines 40-41) -- Remove validation check (lines 48-50) -- Remove `avatarType` from data object (lines 60-62) -- Remove `avatarType` from response (line 76) - ---- - -### Task 8: OAuth — remove profile requests, only email - -**Files:** -- Modify: `server/src/routes/oauth-social.js` - -- [ ] **Step 1: Update findOrCreateUserFromOAuth to remove fallback email** - -Replace `findOrCreateUserFromOAuth` function: - -```js -async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) { - const existingLink = await prisma.oAuthAccount.findUnique({ - where: { provider_providerUserId: { provider, providerUserId } }, - include: { user: true }, - }) - if (existingLink?.user) { - if (accessToken !== undefined) { - await prisma.oAuthAccount.update({ - where: { provider_providerUserId: { provider, providerUserId } }, - data: { accessToken }, - }) - } - return existingLink.user - } - - const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : '' - const norm = trimmed ? normalizeEmail(trimmed) : null - - if (linkToUserId) { - if (!norm) return null - await prisma.oAuthAccount.create({ - data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, - }) - return prisma.user.findUnique({ where: { id: linkToUserId } }) - } - - let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null - if (user) { - await prisma.oAuthAccount.create({ - data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, - }) - return user - } - - if (!norm) return null - - user = await prisma.user.create({ - data: { - email: norm, - displayName: norm.split('@')[0], - avatar: null, - avatarStyle: 'avataaars', - }, - }) - await prisma.oAuthAccount.create({ - data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, - }) - await prisma.notificationPreference.create({ - data: { userId: user.id, globalEnabled: true }, - }) - return user -} -``` - -- [ ] **Step 2: Update VK callback — remove users.get and profile fields** - -Replace VK callback body after token exchange (from line 115), removing the users.get call and profile field extraction: - -```js -const vkUserId = tokenBody?.user_id -const accessTokenVk = tokenBody?.access_token -const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null - -if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') - -// statePayload already parsed in the state verify block above (see Step 4) -const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined - -const user = await findOrCreateUserFromOAuth({ - provider: 'vk', - providerUserId: String(vkUserId), - accessToken: accessTokenVk ?? null, - suggestedEmail: emailSuggestion, - linkToUserId, -}) - -if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') - -if (linkToUserId) { - const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' - return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`) -} - -const token = await issueUserJwt(fastify, user.id, user.email) -return clientRedirect(fastify, reply, token) -``` - -- [ ] **Step 3: Update Yandex callback — remove profile fields, only email** - -Update the authorize scope (line 179): -```diff -- url.searchParams.set('scope', 'login:email login:info') -+ url.searchParams.set('scope', 'login:email') -``` - -Replace Yandex callback body after `/info` call (from line 230), removing profile field extraction: - -```js -const yaUserId = String(info?.id || '') -if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex') - -const emailGuess = - (Array.isArray(info?.emails) && info.emails[0]) || - info?.default_email || - null - -if (!emailGuess) return oauthErrorRedirect(reply, 'no_email') - -// statePayload already parsed in the state verify block (see Step 4) -const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined - -const user = await findOrCreateUserFromOAuth({ - provider: 'yandex', - providerUserId: yaUserId, - accessToken: yaToken, - suggestedEmail: emailGuess, - linkToUserId, -}) - -if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс') - -if (linkToUserId) { - const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' - return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`) -} - -const token = await issueUserJwt(fastify, user.id, user.email) -return clientRedirect(fastify, reply, token) -``` - -- [ ] **Step 4: Update state verify to parse state payload** - -In VK callback, replace lines 89-93: -```js -let statePayload = null -try { - const raw = typeof query.state === 'string' ? query.state : '' - statePayload = fastify.jwt.verify(raw || '') -} catch { - return oauthErrorRedirect(reply, 'Недействительный state OAuth') -} -``` - -In Yandex callback, replace lines 189-194 the same way: -```js -let statePayload = null -try { - const raw = typeof query.state === 'string' ? query.state : '' - statePayload = fastify.jwt.verify(raw || '') -} catch { - return oauthErrorRedirect(reply, 'Недействительный state OAuth') -} -``` - ---- - -### Task 9: OAuth — add link route - -**Files:** -- Modify: `server/src/routes/oauth-social.js` -- Modify: `server/src/plugins/auth.js` - -- [ ] **Step 1: Add GET /api/auth/oauth/{provider}/link** - -Add route in `registerOAuthSocialRoutes`, after each provider's main route but before the callback: - -```js -fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) - if (request.user.email === adminEmail) { - return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) - } - - const clientId = process.env.VK_CLIENT_ID - const clientSecret = process.env.VK_CLIENT_SECRET - if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) - - const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` - const state = fastify.jwt.sign( - { oauth: 'vk', action: 'link', userId: request.user.sub }, - { expiresIn: '15m' }, - ) - - const url = new URL('https://oauth.vk.com/authorize') - url.searchParams.set('client_id', clientId) - url.searchParams.set('display', 'page') - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'email') - url.searchParams.set('response_type', 'code') - url.searchParams.set('v', '5.199') - url.searchParams.set('state', state) - - return reply.redirect(url.toString()) -}) -``` - -And for Yandex: - -```js -fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) - if (request.user.email === adminEmail) { - return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) - } - - const clientId = process.env.YANDEX_CLIENT_ID - if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' }) - - const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` - const state = fastify.jwt.sign( - { oauth: 'yandex', action: 'link', userId: request.user.sub }, - { expiresIn: '15m' }, - ) - - const url = new URL('https://oauth.yandex.ru/authorize') - url.searchParams.set('response_type', 'code') - url.searchParams.set('client_id', clientId) - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('scope', 'login:email') - url.searchParams.set('state', state) - - return reply.redirect(url.toString()) -}) -``` - ---- - -### Task 10: Account linking API — auth-methods, password, unlink - -**Files:** -- Modify: `server/src/routes/auth.js` - -- [ ] **Step 1: Add GET /api/me/auth-methods** - -Add route in `registerAuthRoutes`, after `/api/me`: - -```js -fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub - const user = await prisma.user.findUnique({ - where: { id: userId }, - include: { oauthAccounts: { select: { provider: true } } }, - }) - if (!user) return { methods: [] } - - const providers = user.oauthAccounts.map((a) => a.provider) - return { - methods: [ - { type: 'password', active: Boolean(user.passwordHash) }, - { type: 'vk', active: providers.includes('vk') }, - { type: 'yandex', active: providers.includes('yandex') }, - ], - } -}) -``` - -- [ ] **Step 2: Add POST /api/me/password** - -Add route after auth-methods: - -```js -fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - if (isAdminEmail(request.user.email)) { - return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' }) - } - - const user = await prisma.user.findUnique({ where: { id: userId } }) - if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) - if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' }) - - const password = String(request.body?.password || '') - const passwordErr = validatePassword(password) - if (passwordErr) return reply.code(400).send({ error: passwordErr }) - - const passwordHash = await hashPassword(password) - await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) - - return { ok: true } -}) -``` - -- [ ] **Step 3: Add DELETE /api/me/oauth/{provider}** - -Add route after /api/me/password: - -```js -fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const provider = request.params?.provider - - if (isAdminEmail(request.user.email)) { - return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' }) - } - if (provider !== 'vk' && provider !== 'yandex') { - return reply.code(400).send({ error: 'Неизвестный провайдер' }) - } - - const oauth = await prisma.oAuthAccount.findFirst({ - where: { userId, provider }, - }) - if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' }) - - const remainingOAuth = await prisma.oAuthAccount.count({ - where: { userId, provider: { not: provider } }, - }) - const user = await prisma.user.findUnique({ where: { id: userId }, select: { passwordHash: true } }) - if (!user?.passwordHash && remainingOAuth === 0) { - return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' }) - } - - await prisma.oAuthAccount.delete({ where: { id: oauth.id } }) - return { ok: true } -}) -``` - ---- - -### Task 11: Server tests — password auth endpoints - -**Files:** -- Create: `server/src/routes/__tests__/auth-password.test.js` - -- [ ] **Step 1: Write tests** - -```js -import Fastify from 'fastify' -import jwt from '@fastify/jwt' -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' -import { prisma } from '../../lib/prisma.js' -import { registerAuthRoutes } from '../auth.js' - -const JWT_SECRET = 'test-secret' -const TEST_EMAIL = `test-reg-${Date.now()}@example.com` - -async function buildApp() { - const app = Fastify({ logger: false }) - await app.register(jwt, { secret: JWT_SECRET }) - app.decorate('authenticate', async function (request, reply) { - try { - await request.jwtVerify() - } catch { - return reply.code(401).send({ error: 'Unauthorized' }) - } - }) - app.decorate('eventBus', { emit: () => {} }) - await registerAuthRoutes(app) - await app.ready() - return app -} - -describe('POST /api/auth/register', () => { - let app - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) - afterEach(async () => { - await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }) - }) - - it('registers a new user with password', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Test123!@' }, - }) - expect(res.statusCode).toBe(201) - const body = JSON.parse(res.body) - expect(body.token).toBeTruthy() - expect(body.user.email).toBe(TEST_EMAIL) - expect(body.user.displayName).toBe('test-reg') - }) - - it('rejects duplicate email', async () => { - await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Test123!@' }, - }) - const res = await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Test123!@' }, - }) - expect(res.statusCode).toBe(409) - }) - - it('rejects weak password — too short', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Ab1!' }, - }) - expect(res.statusCode).toBe(400) - const body = JSON.parse(res.body) - expect(body.error).toContain('не менее 8') - }) - - it('rejects weak password — no digit', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Abcdefgh!' }, - }) - expect(res.statusCode).toBe(400) - expect(JSON.parse(res.body).error).toContain('цифру') - }) - - it('rejects weak password — no special char', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: TEST_EMAIL, password: 'Abcdefg1' }, - }) - expect(res.statusCode).toBe(400) - expect(JSON.parse(res.body).error).toContain('спецсимвол') - }) -}) - -describe('POST /api/auth/login', () => { - let app - const loginEmail = `test-login-${Date.now()}@example.com` - - beforeAll(async () => { - app = await buildApp() - await app.inject({ - method: 'POST', url: '/api/auth/register', - payload: { email: loginEmail, password: 'Test123!@' }, - }) - }) - afterAll(async () => { - await prisma.user.deleteMany({ where: { email: loginEmail } }) - await app.close() - }) - - it('logs in with correct password', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/login', - payload: { email: loginEmail, password: 'Test123!@' }, - headers: { 'x-forwarded-for': '1.1.1.1' }, - }) - expect(res.statusCode).toBe(200) - expect(JSON.parse(res.body).token).toBeTruthy() - }) - - it('rejects wrong password', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/login', - payload: { email: loginEmail, password: 'Wrong!!1!' }, - headers: { 'x-forwarded-for': '2.2.2.2' }, - }) - expect(res.statusCode).toBe(401) - }) - - it('rejects non-existent email', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/login', - payload: { email: 'nobody@nowhere.test', password: 'Test123!@' }, - headers: { 'x-forwarded-for': '3.3.3.3' }, - }) - expect(res.statusCode).toBe(401) - }) - - it('returns 403 for admin email', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/auth/login', - payload: { email: process.env.ADMIN_EMAIL || 'admin@test.local', password: 'Test123!@' }, - headers: { 'x-forwarded-for': '4.4.4.4' }, - }) - if (process.env.ADMIN_EMAIL) { - expect(res.statusCode).toBe(403) - } - }) -}) -``` - -- [ ] **Step 2: Run tests** - -```bash -cd server && npx vitest run src/routes/__tests__/auth-password.test.js -``` - -Expected: all tests pass. - ---- - -### Task 12: Server tests — auth-methods, password, unlink - -**Files:** -- Create: `server/src/routes/__tests__/auth-methods.test.js` - -- [ ] **Step 1: Write tests** - -```js -import Fastify from 'fastify' -import jwt from '@fastify/jwt' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { prisma } from '../../lib/prisma.js' -import { registerAuthRoutes } from '../auth.js' - -const JWT_SECRET = 'test-secret' - -async function buildApp() { - const app = Fastify({ logger: false }) - await app.register(jwt, { secret: JWT_SECRET }) - app.decorate('authenticate', async function (request, reply) { - try { await request.jwtVerify() } catch { - return reply.code(401).send({ error: 'Unauthorized' }) - } - }) - app.decorate('eventBus', { emit: () => {} }) - await registerAuthRoutes(app) - await app.ready() - return app -} - -function signToken(app, userId, email) { - return app.jwt.sign({ sub: userId, email }) -} - -async function createUser(email) { - const user = await prisma.user.create({ - data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' }, - }) - await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } }) - return user -} - -describe('GET /api/me/auth-methods', () => { - let app, user, token - const email = `test-methods-${Date.now()}@example.com` - - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) - - beforeEach(async () => { - await prisma.user.deleteMany({ where: { email } }) - user = await createUser(email) - token = signToken(app, user.id, email) - }) - - it('returns methods for user without any method', async () => { - const res = await app.inject({ - method: 'GET', url: '/api/me/auth-methods', - headers: { authorization: `Bearer ${token}` }, - }) - expect(res.statusCode).toBe(200) - const body = JSON.parse(res.body) - expect(body.methods.find((m) => m.type === 'password').active).toBe(false) - expect(body.methods.find((m) => m.type === 'vk').active).toBe(false) - expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false) - }) - - it('returns password as active after setting it', async () => { - await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) - const res = await app.inject({ - method: 'GET', url: '/api/me/auth-methods', - headers: { authorization: `Bearer ${token}` }, - }) - expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true) - }) -}) - -describe('POST /api/me/password', () => { - let app, user, token - const email = `test-set-pw-${Date.now()}@example.com` - - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) - - beforeEach(async () => { - await prisma.user.deleteMany({ where: { email } }) - user = await createUser(email) - token = signToken(app, user.id, email) - }) - - it('sets password', async () => { - const res = await app.inject({ - method: 'POST', url: '/api/me/password', - headers: { authorization: `Bearer ${token}` }, - payload: { password: 'Test123!@' }, - }) - expect(res.statusCode).toBe(200) - - const u = await prisma.user.findUnique({ where: { id: user.id } }) - expect(u.passwordHash).toBeTruthy() - }) - - it('rejects if password already set', async () => { - await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } }) - const res = await app.inject({ - method: 'POST', url: '/api/me/password', - headers: { authorization: `Bearer ${token}` }, - payload: { password: 'Test123!@' }, - }) - expect(res.statusCode).toBe(409) - }) -}) - -describe('DELETE /api/me/oauth/:provider', () => { - let app, user, token - const email = `test-unlink-${Date.now()}@example.com` - - beforeAll(async () => { app = await buildApp() }) - afterAll(async () => { await app.close() }) - - beforeEach(async () => { - await prisma.user.deleteMany({ where: { email } }) - user = await createUser(email) - token = signToken(app, user.id, email) - }) - - it('returns 404 for non-linked provider', async () => { - const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', - headers: { authorization: `Bearer ${token}` }, - }) - expect(res.statusCode).toBe(404) - }) - - it('unlinks a provider', async () => { - await prisma.oAuthAccount.create({ - data: { provider: 'vk', providerUserId: '123', userId: user.id }, - }) - const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', - headers: { authorization: `Bearer ${token}` }, - }) - expect(res.statusCode).toBe(200) - - const count = await prisma.oAuthAccount.count({ where: { userId: user.id } }) - expect(count).toBe(0) - }) - - it('rejects removing last method without password', async () => { - await prisma.oAuthAccount.create({ - data: { provider: 'vk', providerUserId: '123', userId: user.id }, - }) - const res = await app.inject({ - method: 'DELETE', url: '/api/me/oauth/vk', - headers: { authorization: `Bearer ${token}` }, - }) - expect(res.statusCode).toBe(400) - expect(JSON.parse(res.body).error).toContain('последний метод') - }) -}) -``` - -- [ ] **Step 2: Run tests** - -```bash -cd server && npx vitest run src/routes/__tests__/auth-methods.test.js -``` - -Expected: all tests pass. - ---- - -### Task 13: Run all server tests together - -- [ ] **Step 1: Run full server test suite** - -```bash -cd server && npx vitest run -``` - -Expected: all existing and new tests pass. - ---- - -### Task 14: Client — update Effector auth model - -**Files:** -- Modify: `client/src/shared/model/auth.ts` - -- [ ] **Step 1: Remove avatarType from AuthUser type and add new effects** - -```ts -import { createEffect, createEvent, createStore, sample } from 'effector' -import { apiClient } from '@/shared/api/client' -import { createErrorStore } from '@/shared/lib/create-error-store' -import { persistToken } from '@/shared/lib/persist-token' - -export type AuthUser = { - id: string - email: string - displayName?: string | null - firstName?: string | null - lastName?: string | null - gender?: string | null - avatar?: string | null - avatarStyle?: string | null - isAdmin?: boolean -} - -export type AuthMethod = { - type: 'password' | 'vk' | 'yandex' - active: boolean -} - -export const tokenSet = createEvent() -export const logout = createEvent() - -// ----- Token persistence ----- - -const persistTokenFx = createEffect({ - handler: (token) => persistToken(token), -}) - -export const $token = createStore(null) - .on(tokenSet, (_, t) => t) - .reset(logout) - -sample({ clock: $token, target: persistTokenFx }) - -// ----- User ----- - -export const $user = createStore(null).reset(logout) - -export const meFx = createEffect(async (token: string) => { - const { data } = await apiClient.get<{ user: AuthUser | null }>('me', { - headers: { Authorization: `Bearer ${token}` }, - }) - return data.user -}) - -sample({ clock: tokenSet, filter: (t): t is string => Boolean(t), target: meFx }) - -sample({ clock: meFx.doneData, target: $user }) - -// ----- Email change ----- - -export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => { - await apiClient.post('me/change-email/request-code', { newEmail }) -}) - -export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => { - const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params) - return data.user -}) - -// ----- Profile update ----- - -export type UpdateProfileParams = { - displayName: string | null - avatar?: string | null - avatarStyle?: string | null -} - -export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => { - const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) - return data.user -}) - -// ----- Login / Register ----- - -export const loginFx = createEffect(async (params: { email: string; password: string }) => { - const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/login', params) - tokenSet(data.token) - return data.user -}) - -export const registerFx = createEffect( - async (params: { email: string; password: string; displayName?: string }) => { - const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) - tokenSet(data.token) - return data.user - }, -) - -// ----- Auth methods ----- - -export const fetchAuthMethodsFx = createEffect(async () => { - const { data } = await apiClient.get<{ methods: AuthMethod[] }>('me/auth-methods') - return data.methods -}) - -export const setPasswordFx = createEffect(async (password: string) => { - await apiClient.post('me/password', { password }) -}) - -export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => { - await apiClient.delete(`me/oauth/${provider}`) -}) - -// ----- Error stores ----- - -export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error -export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error -export const $updateProfileError = createErrorStore(updateProfileFx).$error - -// ----- Re-exports ----- - -export { readStoredToken } from '@/shared/lib/persist-token' - -// ----- Sync user from profile/email changes ----- - -sample({ clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData], target: $user }) -``` - ---- - -### Task 15: Client — update UserAvatar (remove avatarType) - -**Files:** -- Modify: `client/src/shared/ui/UserAvatar.tsx` - -- [ ] **Step 1: Remove avatarType prop and always use DiceBear fallback** - -```tsx -import { useMemo } from 'react' -import Avatar from '@mui/material/Avatar' -import type { SxProps, Theme } from '@mui/material/styles' -import { createAvatar } from '@dicebear/core' -import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' - -type UserAvatarProps = { - userId: string - avatarUrl?: string | null - avatarStyle?: string | null - size?: number - sx?: SxProps -} - -export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { - const generatedSrc = useMemo(() => { - const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) - const avatar = createAvatar(styleDef.style, { seed: userId }) - return avatar.toDataUri() - }, [userId, avatarStyle]) - - const src = avatarUrl || generatedSrc - - return ( - - ? - - ) -} -``` - ---- - -### Task 16: Client — update UserAvatar usages (remove avatarType prop) - -- [ ] **Step 1: Find and update all UserAvatar usages** - -Search for all `avatarType` passed to `UserAvatar` and remove them. Files to modify: - -- `client/src/pages/me/ui/sections/SettingsPage.tsx` — lines 131, 148 (remove `avatarType` prop) -- `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` — lines 147, 164 (remove `avatarType` prop) -- `client/src/features/user/user-menu/ui/UserMenu.tsx` — check for UserAvatar usage -- Any other files using UserAvatar with avatarType - -Remove `avatarType` prop from each `` usage. The prop no longer exists on the component. - ---- - -### Task 17: Client — rewrite AuthPage with tabs - -**Files:** -- Modify: `client/src/pages/auth/ui/AuthPage.tsx` - -- [ ] **Step 1: Rewrite complete AuthPage** +Write the entire file: ```tsx import { useEffect, useState } from 'react' +import { alpha, useTheme } from '@mui/material/styles' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import Paper from '@mui/material/Paper' import Stack from '@mui/material/Stack' -import Tab from '@mui/material/Tab' -import Tabs from '@mui/material/Tabs' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' +import { Lock, Mail } from 'lucide-react' import { useForm } from 'react-hook-form' import { useNavigate, useSearchParams } from 'react-router-dom' import { OAuthButtons } from '@/features/auth-oauth' import { apiClient } from '@/shared/api/client' -import { $user, loginFx, registerFx, tokenSet } from '@/shared/model/auth' +import { $user, tokenSet } from '@/shared/model/auth' +import { BearLogo } from '@/shared/ui/BearLogo' type AuthResponse = { token: string @@ -1246,6 +171,7 @@ function getApiErrorMessage(err: unknown): string | null { } export function AuthPage() { + const theme = useTheme() const [message, setMessage] = useState(null) const [oauthError, setOauthError] = useState(null) const [tab, setTab] = useState(0) @@ -1326,450 +252,291 @@ export function AuthPage() { isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null return ( - - - Вход / регистрация - + + + + + - {message && {message}} - {oauthError && ( - setOauthError(null)}>{oauthError} - )} - {errMsg && {errMsg}} + + Добро пожаловать в Любимый Креатив + - setTab(v)} sx={{ mb: 3 }}> - - - - + + Войдите или зарегистрируйтесь, чтобы продолжить + - {tab === 0 && ( - - - - + + + {[ + { label: 'Пароль', idx: 0 }, + { label: 'Код', idx: 1 }, + { label: 'Другой способ', idx: 2 }, + ].map(({ label, idx }) => ( + + ))} - - - {isRegister && ( - - )} - - - - {isRegister && ( - - )} - - {isRegister ? ( - - ) : ( - - )} - - )} - - {tab === 1 && ( - - - - - - - - - )} + {errMsg || oauthError} + + )} + {message && ( + setMessage(null)}> + {message} + + )} - {tab === 2 && ( - - - - )} + {tab === 0 && ( + + + + + + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + + + + ), + }, + }} + /> + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} + + )} + + {tab === 1 && ( + + + + + ), + }, + }} + /> + + + + + + + )} + + {tab === 2 && ( + + + + )} + + ) } ``` ---- +- [ ] **Step 3: Run typecheck** -### Task 18: Client — add auth methods section to SettingsPage - -**Files:** -- Modify: `client/src/pages/me/ui/sections/SettingsPage.tsx` - -- [ ] **Step 1: Simplify avatar section — remove OAuth avatar switching** - -Replace the avatar section's state variables (lines 59-61): -```tsx -const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') -const useOAuth = user?.avatarType === 'oauth' -const useGenerated = user?.avatarType === 'generated' +```bash +cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -20 ``` -With: -```tsx -// no more avatarType — always internal avatars +Expected: no errors. + +- [ ] **Step 4: Run tests** + +```bash +cd /mnt/d/my_projects/shop/client && npx vitest run ``` -And replace the caption (lines 140): -```diff -- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} -+ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} -``` +Expected: all tests pass (7 files, 29 tests). -Remove the "Use OAuth" button block entirely (lines 208-222): -```diff -- {hasOAuthAvatar && !hasUnsavedPreview && ( -- -- )} -``` +- [ ] **Step 5: Commit** -And in UserAvatar usage, remove `avatarType` prop (lines 131, 148): -```diff -- avatarType={hasUnsavedPreview ? 'generated' : user.avatarType} -``` -(just delete that prop line) - -Remove `avatarType` from updateProfileFx calls (lines 191-197 and any other): -```diff - updateProfileFx({ - displayName: user.displayName?.trim() || null, - avatar: previewSrc, -- avatarType: 'generated', - avatarStyle: previewStyle, - }) -``` - -- [ ] **Step 2: Add auth methods section — imports and state** - -Add imports at top: -```tsx -import { useCallback } from 'react' -import Chip from '@mui/material/Chip' -import { - fetchAuthMethodsFx, - setPasswordFx, - unlinkOAuthFx, - type AuthMethod, -} from '@/shared/model/auth' -``` - -Add state and data loading after existing hooks: -```tsx -const [authMethods, setAuthMethods] = useState([]) -const [showSetPassword, setShowSetPassword] = useState(false) -const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ - defaultValues: { password: '', passwordConfirm: '' }, -}) - -useEffect(() => { - fetchAuthMethodsFx().then(setAuthMethods).catch(() => { - setAuthMethods([]) - }) -}, []) - -const setPasswordMutation = useMutation({ - mutationFn: async (pw: string) => { - await setPasswordFx(pw) - const methods = await fetchAuthMethodsFx() - setAuthMethods(methods) - setShowSetPassword(false) - }, - onError: () => {}, -}) - -const unlinkMutation = useMutation({ - mutationFn: async (provider: 'vk' | 'yandex') => { - await unlinkOAuthFx(provider) - const methods = await fetchAuthMethodsFx() - setAuthMethods(methods) - }, - onError: () => {}, -}) - -const linkedCount = useCallback(() => { - return authMethods.filter((m) => m.active).length -}, [authMethods]) - -const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } -``` - -- [ ] **Step 3: Add auth methods section UI** - -Insert after the avatar section's closing `` + `` (before email change section), but only if `!user.isAdmin`: - -```tsx -{!user.isAdmin && ( - <> - - - - Методы входа - - - {authMethods.map((m) => ( - - {METHOD_LABELS[m.type] || m.type} - - {m.active && m.type !== 'password' && ( - - )} - {!m.active && m.type === 'password' && ( - - )} - {!m.active && m.type !== 'password' && ( - - )} - - ))} - - - {showSetPassword && ( - - - - - - - - - )} - - -)} +```bash +cd /mnt/d/my_projects/shop +git add client/src/pages/auth/ui/AuthPage.tsx +git commit -m "feat(client): redesign auth page with minimal style, BearLogo, pill buttons" ``` --- -### Task 19: Client — update AdminSettingsPage (remove avatarType) +### Task 3: Run full verification -**Files:** -- Modify: `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` - -- [ ] **Step 1: Remove avatarType and OAuth avatar references** - -The AdminSettingsPage mirrors SettingsPage. Make these specific changes: - -1. Remove state lines (find the equivalent of `hasOAuthAvatar`, `useOAuth`, `useGenerated`): -```diff -- const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') -- const useOAuth = user?.avatarType === 'oauth' -- const useGenerated = user?.avatarType === 'generated' -``` - -2. Remap the caption line: -```diff -- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} -+ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} -``` - -3. Remove the "Use OAuth" button block (find lines starting with `{hasOAuthAvatar && !hasUnsavedPreview`). - -4. Remove `avatarType` prop from all `` usages in this file. - -5. Remove `avatarType` from any `updateProfileFx` or admin profile API calls in this file. Find the PATCH call payload and remove the `avatarType` field. - ---- - -### Task 20: Client tests - -**Files:** -- Create: `client/src/pages/auth/__tests__/AuthPage.test.tsx` - -- [ ] **Step 1: Write AuthPage tests** - -```tsx -import { render, screen, fireEvent } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { MemoryRouter } from 'react-router-dom' -import { describe, expect, it, vi } from 'vitest' -import { AuthPage } from '../ui/AuthPage' - -vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) -vi.mock('effector-react', async () => { - const actual = await vi.importActual('effector-react') - return { ...actual, useUnit: () => null } -}) - -function renderPage() { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - return render( - - - - - , - ) -} - -describe('AuthPage', () => { - it('renders three tabs', () => { - renderPage() - expect(screen.getByText('Пароль')).toBeTruthy() - expect(screen.getByText('Код')).toBeTruthy() - expect(screen.getByText('Другой способ')).toBeTruthy() - }) - - it('shows login form by default on tab 0', () => { - renderPage() - expect(screen.getByText('Вход')).toBeTruthy() - expect(screen.getByText('Регистрация')).toBeTruthy() - const buttons = screen.getAllByRole('button') - const loginBtn = buttons.find((b) => b.textContent === 'Войти') - expect(loginBtn).toBeTruthy() - }) - - it('switches to register form', () => { - renderPage() - fireEvent.click(screen.getByText('Регистрация')) - expect(screen.getByText('Зарегистрироваться')).toBeTruthy() - }) - - it('switches to code tab', () => { - renderPage() - fireEvent.click(screen.getByText('Код')) - expect(screen.getByText('Отправить код')).toBeTruthy() - }) - - it('switches to OAuth tab', () => { - renderPage() - fireEvent.click(screen.getByText('Другой способ')) - expect(screen.getByText('Войти через VK ID')).toBeTruthy() - expect(screen.getByText('Войти через Яндекс ID')).toBeTruthy() - }) -}) -``` - -- [ ] **Step 2: Run client tests** +- [ ] **Step 1: Client lint + format + build** ```bash -cd client && npx vitest run src/pages/auth/__tests__/AuthPage.test.tsx +cd /mnt/d/my_projects/shop/client +npm run lint +npm run format:check +npm run build ``` -Expected: all 5 tests pass. +Expected: 0 errors, format clean, build success. ---- - -### Task 21: Run full test suite - -- [ ] **Step 1: Run server tests** +- [ ] **Step 2: Server tests (regression check)** ```bash -cd server && npx vitest run +cd /mnt/d/my_projects/shop/server && npx vitest run ``` -- [ ] **Step 2: Run client tests** +Expected: all pass (ignore pre-existing user-payments.test.js failures if any). + +- [ ] **Step 3: Commit if anything changed** ```bash -cd client && npx vitest run +cd /mnt/d/my_projects/shop +git add -A +git diff --cached --quiet || git commit -m "chore: post-redesign lint fixes" ``` - -- [ ] **Step 3: Run client lint + format check** - -```bash -cd client && npm run lint && npm run format:check -``` - -- [ ] **Step 4: Run client build** - -```bash -cd client && npm run build -``` - -All must pass. diff --git a/docs/superpowers/specs/2026-05-22-auth-redesign-design.md b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md index af2cad9..6bf2444 100644 --- a/docs/superpowers/specs/2026-05-22-auth-redesign-design.md +++ b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md @@ -1,300 +1,225 @@ -# Auth Redesign — Spec +# Auth Page Redesign — Spec **Date:** 2026-05-22 -**Goal:** Переработать систему аутентификации: OAuth запрашивает только email, убрать внешние аватары, добавить вход по email+паролю, дать пользователям связывать методы входа в ЛК. +**Goal:** Минималистичный редизайн страницы входа с лёгким брендингом (медведь + слоган), pill-кнопками и Paper-карточкой. + +**Style:** Минималистичный, чистый. Одна колонка по центру. --- -## 1. Data Model (Prisma) +## 1. Шрифт Outfit -### 1.1. Модель `User` — изменения +**Проблема:** Outfit указан в MUI-теме, но не загружается. Фактически везде системный Segoe UI. -| Поле | Было | Стало | -|------|------|-------| -| `passwordHash` | `String?` (не использовалось) | Задействуем. Хранит bcrypt-хеш. `null` если пароль не установлен. | -| `avatarType` | `String?` (`'oauth'` / `'generated'`) | **Удалить.** Все аватары внутренние (DiceBear). | -| `avatar` | `String?` (URL или data:uri) | Только DiceBear URL или `null` (генерируется на лету) | -| `avatarStyle` | `String?` | Без изменений. | +**Исправление:** Скачать шрифт Outfit (woff2, веса 400/500/600/700) и разместить в `client/public/fonts/`. Добавить `@font-face` в `client/src/app/styles/global.css`: -Остальные поля (`id`, `email`, `displayName`, `firstName`, `lastName`, `gender`, `createdAt`, `updatedAt`) — без изменений. `firstName`, `lastName`, `gender` больше не заполняются при OAuth, но остаются в БД (могут быть заполнены пользователем вручную позже). - -### 1.2. Модель `OAuthAccount` — без изменений - -```prisma -model OAuthAccount { - id String @id @default(cuid()) - provider String // 'vk' | 'yandex' - providerUserId String - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - accessToken String? - refreshToken String? // зарезервировано, не используется сейчас - createdAt DateTime @default(now()) - - @@unique([provider, providerUserId]) +```css +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + src: url('/fonts/Outfit-Regular.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + src: url('/fonts/Outfit-Medium.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + src: url('/fonts/Outfit-SemiBold.woff2') format('woff2'); +} +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + src: url('/fonts/Outfit-Bold.woff2') format('woff2'); } ``` -### 1.3. Модель `AuthCode` — без изменений - -### 1.4. Миграции - -1. Удаление колонки `avatarType` из `User` -2. Миграция данных: для всех пользователей с `avatarType = 'oauth'` установить `avatar = null` (внешние URL больше не используются, аватар перегенерируется DiceBear) +Файлы woff2 скачать с Google Fonts или из CDN и положить в `client/public/fonts/`. --- -## 2. Авторизация по email+паролю +## 2. Фон страницы -### 2.1. Регистрация - -`POST /api/auth/register` (новый, без аутентификации) - -**Request:** -```json -{ - "email": "user@example.com", - "password": "Abcdef1!", - "displayName": "Иван" // optional -} -``` - -**Валидация:** -- `email`: валидный email, нормализация (trim + lowercase), уникальность -- `password`: минимум 8 символов, минимум 1 буква, 1 цифра, 1 спецсимвол -- `displayName`: опционально, строка до 100 символов. Если не передан — берётся часть email до `@` - -**Логика:** -1. Проверка, что email не занят → 409 если занят -2. `passwordHash = await bcrypt.hash(password, 10)` -3. Создание пользователя: `email`, `passwordHash`, `displayName`, `avatar = null`, `avatarStyle = 'avataaars'` -4. Создание `NotificationPreference` (как сейчас в verify-code) -5. Возврат JWT + user - -**Response 201:** -```json -{ - "token": "jwt...", - "user": { "id", "email", "displayName", "avatar", "avatarStyle", "isAdmin": false } -} -``` - -### 2.2. Вход - -`POST /api/auth/login` (новый, без аутентификации) - -**Request:** -```json -{ - "email": "user@example.com", - "password": "Abcdef1!" -} -``` - -**Rate limit:** максимум 5 попыток в минуту с одного IP (использовать `@fastify/rate-limit`). При превышении — `429 Too Many Requests`. - -**Логика:** -1. Нормализация email -2. Поиск пользователя по email -3. Если пользователь не найден ИЛИ `passwordHash === null` → `401 Invalid email or password` (одинаковый ответ для безопасности) -4. `await bcrypt.compare(password, user.passwordHash)` → если не совпадает → `401` -5. Возврат JWT + user - -### 2.3. Админ и пароль - -- Админ (`email === ADMIN_EMAIL`) **не может** зарегистрироваться или войти по паролю -- `POST /api/auth/register` и `POST /api/auth/login` возвращают `403` для админского email -- Админ также **не может** установить пароль через `POST /api/me/password` +- `background.default` + лёгкий радиальный градиент +- Градиент: от центра к краям, `primary.main` с 3-5% opacity +- Реализация: `sx` prop на корневом ``: `background: radial-gradient(circle at 50% 30%, ${alpha(theme.palette.primary.main, 0.05)} 0%, transparent 70%)` --- -## 3. OAuth (только email) - -### 3.1. Scope - -| Провайдер | Было | Стало | -|-----------|------|-------| -| VK | `email` | `email` (без изменений, но больше не запрашиваем профиль) | -| Яндекс | `login:email login:info` | `login:email` | - -### 3.2. Callback — что убираем - -**VK:** -- Больше не делаем `users.get` после получения токена -- Не сохраняем: `first_name`, `last_name`, `photo_200`, `sex` - -**Яндекс:** -- Всё ещё вызываем `GET https://login.yandex.ru/info` (нужен для получения email) -- Из ответа берём только `default_email` или первый из `emails` -- **Не сохраняем:** `first_name`, `last_name`, `display_name`, `sex`, `default_avatar_id` - -### 3.3. Callback — новая логика +## 3. Компоновка ``` -1. Обмен code на access_token (как сейчас) -2. Извлечение email из ответа провайдера: - - VK: поле `email` в ответе access_token - - Яндекс: вызываем `/info`, из ответа берём `default_email` или первый из `emails` -3. Если email отсутствует → редирект с ?oauthError=no_email -4. Нормализация email -5. Поиск пользователя по email: - a) Найден → привязываем OAuthAccount (если ещё не привязан), возвращаем JWT - b) Не найден → создаём нового: - - email - - displayName = часть email до @ - - avatar = null - - avatarStyle = 'avataaars' - - Создаём OAuthAccount - - Создаём NotificationPreference - - Возвращаем JWT -6. Редирект на CLIENT_PUBLIC_URL/auth/callback?token=... +┌──────────────────────────────────────────┐ +│ (воздух) │ +│ 🐻 BearLogo 72px │ +│ Добро пожаловать в Любимый Креатив │ +│ (subtitle, text.secondary) │ +│ (воздух) │ +│ ┌─────── Paper 440px max-width ──────┐ │ +│ │ [Пароль] [Код] [Другой способ] │ │ +│ │ │ │ +│ │ Вход / Регистрация │ │ +│ │ │ │ +│ │ Email: __________________ │ │ +│ │ Пароль: __________________ │ │ +│ │ │ │ +│ │ [────────── Войти ──────────] │ │ +│ │ │ │ +│ └─────────────────────────────────────┘ │ +│ (воздух) │ +└──────────────────────────────────────────┘ ``` -**Fallback-email `{provider}_{id}@oauth.craftshop.local` — убираем.** Если провайдер не дал email — ошибка. - -### 3.4. State-параметр - -Без изменений. JWT с `expiresIn: 15m` для CSRF-защиты. +Детали: +- Корневой ``: `display: flex, alignItems: center, justifyContent: center, minHeight: calc(100vh - header)` +- BearLogo: `` +- Заголовок: `variant="h5"`, `fontWeight: 700`, `textAlign: center` +- Слоган: `variant="body2"`, `color: text.secondary`, `textAlign: center`, `mb: 3` +- Paper: `maxWidth: 440`, `mx: auto`, `p: 4`, `borderRadius: 3` (12px), `border: 1px solid divider`, мягкая тень --- -## 4. Связывание аккаунтов +## 4. Pill-переключатель методов -### 4.1. Авто-связывание +Вместо MUI Tabs — три MUI Button в ряд: -При OAuth-входе: если email из OAuth совпадает с email существующего пользователя — автоматически создаётся `OAuthAccount`, связывающий провайдера с пользователем. Вход происходит мгновенно. - -### 4.2. Ручное связывание — страница настроек `/me` - -**Получение статуса методов:** `GET /api/me/auth-methods` (новый, требует `authenticate`) - -**Response:** -```json -{ - "methods": [ - { "type": "password", "active": true }, - { "type": "vk", "active": false }, - { "type": "yandex", "active": true } - ] -} +```tsx + + + + + ``` -Логика: -- `type: "password"` — `active: user.passwordHash !== null` -- `type: "vk"` / `type: "yandex"` — `active: exists(OAuthAccount)` +--- -### 4.3. Привязка OAuth +## 5. Под-переключатель Вход/Регистрация -`GET /api/auth/oauth/{provider}/link` (новый, требует `authenticate`) +Только на вкладке «Пароль»: -1. Генерирует state-JWT с `{ userId, provider, action: 'link' }`, `expiresIn: 15m` -2. Редиректит на страницу авторизации провайдера -3. После callback проверяет `action: 'link'` в state -4. Создаёт `OAuthAccount` для указанного `userId` (нормальный upsert) -5. Редиректит на `/me?linked={provider}` - -### 4.4. Привязка пароля - -`POST /api/me/password` (новый, требует `authenticate`) - -**Request:** `{ "password": "Abcdef1!" }` - -**Логика:** -1. Если `user.email === ADMIN_EMAIL` → `403` -2. Если `user.passwordHash !== null` → `409 Password already set` (для смены использовать отдельный метод) -3. Валидация пароля (8+, буква+цифра+спецсимвол) -4. `user.passwordHash = await bcrypt.hash(password, 10)` -5. Сохранение пользователя - -### 4.5. Отвязывание - -`DELETE /api/me/oauth/{provider}` (новый, требует `authenticate`) - -**Логика:** -1. Удаление `OAuthAccount` по `userId + provider` -2. Если `OAuthAccount` не найден → `404` -3. **Проверка последнего метода:** после удаления, если у пользователя нет ни `passwordHash`, ни других `OAuthAccount` → `400 Cannot remove last auth method` -4. Возврат `200` - -**Примечание:** отвязывание пароля (установка `passwordHash = null`) пока не делаем — можно добавить позже. - -### 4.6. Админ и связывание - -- `POST /api/me/password` → `403` для админа -- OAuth-привязка через `/link` → `403` для админа -- OAuth-отвязывание → `403` для админа +```tsx + + + + +``` --- -## 5. Email-код (без изменений логики) +## 6. Формы по вкладкам -`POST /api/auth/request-code` и `POST /api/auth/verify-code` работают как раньше, изменений нет. Админ входит только этим способом. +### Пароль (вход) +- Email TextField +- Пароль TextField +- Button contained fullWidth: «Войти» + +### Пароль (регистрация) +- Email TextField +- Имя TextField (опционально, helperText: «Необязательно. Будет использована часть email») +- Пароль TextField +- Подтверждение пароля TextField (с валидацией совпадения) +- Button contained fullWidth: «Зарегистрироваться» + +### Код +- Строка: Email + кнопка «Отправить код» +- Строка: поле Код + кнопка «Войти» +- Alert outlined success после успешной отправки + +### Другой способ +- OAuthButtons — стилизовать кнопки как outlined pill (borderRadius 24px, fullWidth) +- Кнопки: «Войти через Яндекс ID», «Войти через VK ID» --- -## 6. Изменения на клиенте +## 7. Alert'ы -### 6.1. Страница `/auth` - -**3 вкладки:** -- **«Пароль»** — переключатель Вход/Регистрация. Вход: email + пароль. Регистрация: email + пароль + подтверждение пароля + имя (опционально). -- **«Код»** — как сейчас: email → отправить код → ввести код. -- **«Другой способ»** — кнопки Войти через VK / Яндекс. - -### 6.2. Страница `/me` (настройки) - -Новая секция «Методы входа»: -- Список методов с индикаторами «привязан» / «не привязан» -- Кнопки «Привязать» (редирект на OAuth или форма пароля) -- Кнопки «Отвязать» (disabled если это последний метод) -- Для админа — секция скрыта - -### 6.3. Effector-стейт - -- `$token`, `$user`, `tokenSet`, `logout` — без изменений -- Добавить эффекты: `loginFx`, `registerFx`, `linkOAuthFx`, `setPasswordFx`, `unlinkOAuthFx` - -### 6.4. Компоненты - -- `UserAvatar` — убрать проверку `avatarType`, всегда использовать DiceBear (сохранённый `avatar` или генерация на лету) -- `OAuthButtons` — без изменений (URL те же) +- Все ошибки: `Alert severity="error" variant="outlined"` внутри Paper, над формой +- Успешная отправка кода: `Alert severity="success" variant="outlined"` +- OAuth-ошибки: так же внутри Paper --- -## 7. Тестирование +## 8. Иконки в TextField -### 7.1. Серверные тесты +Добавить `InputAdornment` с иконками для визуального улучшения: +- Email: `` иконка (lucide-react) +- Пароль: `` иконка -- `POST /api/auth/register` — успешная регистрация, дубликат email, слабый пароль -- `POST /api/auth/login` — успешный вход, неверный пароль, несуществующий email, превышение rate limit -- OAuth callback — создание нового пользователя с email, авто-связывание по email, ошибка при отсутствии email от провайдера -- `POST /api/me/password` — установка, повторная установка (409), админ (403) -- `GET /api/me/auth-methods` — корректный список методов -- `DELETE /api/me/oauth/{provider}` — отвязывание, последний метод (400), админ (403) -- Админ не может войти через `/login` (403) - -### 7.2. Клиентские тесты - -- Страница `/auth` — наличие трёх вкладок, переключение -- Форма регистрации — валидация пароля, подтверждение -- Форма входа — обработка ошибок -- `/me` — отображение методов, кнопки привязки/отвязки +Иконки только если это не перегружает минималистичный стиль. Решение — использовать в полях email и пароля `startAdornment`. --- -## 8. Миграция существующих пользователей +## 9. Адаптивность -1. Все пользователи с `avatarType = 'oauth'`: `avatar = null`, `avatarType = null`. Аватар перегенерируется DiceBear при следующем отображении. -2. `avatarType` колонка удаляется из БД. -3. Существующие OAuth-аккаунты работают как раньше, но при следующем входе через OAuth обновляется логика (не запрашиваем профиль). -4. `firstName`, `lastName`, `gender` у существующих OAuth-пользователей остаются в БД (не удаляем, просто больше не пополняем из OAuth). +- `min-height` вместо `height` (использовать `minHeight: calc(100vh - 64px)`) +- Paper: `mx: 2` на мобильных, `mx: auto` на десктопе +- Pill-кнопки остаются в ряд на всех разрешениях (они и так компактные) +- Отправка кода: на мобильных поля в столбец (уже есть `direction={{ xs: 'column', sm: 'row' }}`) --- -## 9. Заметки +## 10. Плавные переходы -- **bcrypt** — не установлен, нужно добавить `npm install bcrypt` в server. -- **Rate limit** — `@fastify/rate-limit` не установлен. Добавить или реализовать самодельный in-memory rate limiter (5 попыток/мин/IP). -- Все новые эндпоинты должны валидироваться через JSON Schema (как существующие). -- Пароль никогда не возвращается в ответах API и не логируется. -- Существующий `User.passwordHash` (null у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать. +- Смена вкладок: контент формы — `opacity` transition 200ms +- Смена Вход/Регистрация: поля появляются с fade-in + +--- + +## 11. Заметки + +- BearLogo уже существует (`@/shared/ui/BearLogo`) +- OAuthButtons существует (`@/features/auth-oauth`) +- Менять бизнес-логику (хуки, mutations) не нужно — только вёрстку +- Текущий AuthPage — 232 строки, нужно заменить полностью +- Все цвета брать из темы (`primary.main`, `text.secondary`, `divider`, `background.paper`) +- Для градиента использовать `useTheme` + `alpha` из MUI diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 438cf28..2e676d5 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ