From eb30640b49d2d80dc3c13b73ed9bb85fe8982b17 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 13:18:21 +0500 Subject: [PATCH] feat: load Outfit font from static files --- .../12055-1779436874/state/server.pid | 1 + .../12189-1779436893/state/server.pid | 1 + .../12680-1779437109/state/server.pid | 1 + .../12844-1779437126/state/server.pid | 1 + .../12988-1779437168/state/server.pid | 1 + .../13143-1779437184/state/server.pid | 1 + client/public/fonts/Outfit-Bold.woff2 | Bin 0 -> 14060 bytes client/public/fonts/Outfit-Medium.woff2 | Bin 0 -> 13528 bytes client/public/fonts/Outfit-Regular.woff2 | Bin 0 -> 14032 bytes client/public/fonts/Outfit-SemiBold.woff2 | Bin 0 -> 14140 bytes client/src/app/styles/global.css | 41 +- .../plans/2026-05-22-auth-redesign.md | 1913 +++-------------- .../specs/2026-05-22-auth-redesign-design.md | 419 ++-- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 14 files changed, 548 insertions(+), 1831 deletions(-) create mode 100644 .superpowers/brainstorm/12055-1779436874/state/server.pid create mode 100644 .superpowers/brainstorm/12189-1779436893/state/server.pid create mode 100644 .superpowers/brainstorm/12680-1779437109/state/server.pid create mode 100644 .superpowers/brainstorm/12844-1779437126/state/server.pid create mode 100644 .superpowers/brainstorm/12988-1779437168/state/server.pid create mode 100644 .superpowers/brainstorm/13143-1779437184/state/server.pid create mode 100644 client/public/fonts/Outfit-Bold.woff2 create mode 100644 client/public/fonts/Outfit-Medium.woff2 create mode 100644 client/public/fonts/Outfit-Regular.woff2 create mode 100644 client/public/fonts/Outfit-SemiBold.woff2 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 0000000000000000000000000000000000000000..8674e73594a26b7670d3ba93e42063f39e11c3ee GIT binary patch literal 14060 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 literal 0 HcmV?d00001 diff --git a/client/public/fonts/Outfit-Medium.woff2 b/client/public/fonts/Outfit-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..264ce74280afa9bce3b16dbdd77a42754ba52986 GIT binary patch literal 13528 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 OeG&HNd{LtS0sap##2Lu| literal 0 HcmV?d00001 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 438cf286708da727edf656171d9794758ac2fef9..2e676d5cfc333d1cc0293d64a29de6298127cd8d 100644 GIT binary patch delta 777 zcmZozAl9%zY=Si7y@@i;jQ2JsEa~TAd@(`@}?<4LNfQ z60WSh)L@v;3-&+`1Ah+xY`!UcDnJK{@NHK)$Kt_Q-zdn)Yi}qE zRKXzM$ck_zhFdU`3rr-jEU_dpF|nvv#VAR|&_E^1x5&t{$js9%A}iaVz%w@^+^8(r zze3;7wKyy!vn0zhEhE_^!pWd8ImK1fDa6M#FV7^q670*F5OUiEV0d!Dk|8i53N%FX VPmhgZ;hbLdpQR9gLR`W+0RVeV_s#$S delta 613 zcmZozAl9%zY=Si7&51J3j5jwXEa~TA5MW^7U&r6V-^qV%v!H8sB(N^Ub?dB-n=sd}3LGmFA@76X=7{L?p`XB6V%0{RB* z)Nfk_86WXaf8)>0#l^`YD9i~qxGjKrTL23zFtYf!0zDMRKRv6S#hVwX(c7LA3|LvG zuinoh3AUOC>1xvhbP#Q$!%3l{0XXcuUR=ARxL!@@be=s!y# K{%BvqIspIyBe@~~