diff --git a/docs/superpowers/plans/2026-05-20-yandex-vk-oauth.md b/docs/superpowers/plans/2026-05-20-yandex-vk-oauth.md new file mode 100644 index 0000000..1cbcd6f --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-yandex-vk-oauth.md @@ -0,0 +1,886 @@ +# Yandex ID + VK ID OAuth — 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:** Add Yandex and VK OAuth login buttons to the client, enrich user profiles with firstName/lastName/gender/avatar from OAuth providers, and rename `name` → `displayName` across the project. + +**Architecture:** Server OAuth flow already exists (`oauth-social.js`). We update the User model (rename `name`→`displayName`, add 4 fields), enrich `findOrCreateUserFromOAuth()` to save profile data, then add an FSD feature `features/auth-oauth/` with Yandex/VK login buttons on the AuthPage. + +**Tech Stack:** Prisma (SQLite), Fastify, React + MUI + Effector + TypeScript + +--- + +### Task 1: Update Prisma User model + +**Files:** +- Modify: `server/prisma/schema.prisma:80` + +- [ ] **Step 1: Rename `name` → `displayName` and add new fields** + +```diff + model User { + id String @id @default(cuid()) + email String @unique +- name String? ++ displayName String? ++ firstName String? ++ lastName String? ++ gender String? ++ avatar String? + phone String? + passwordHash String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + codes AuthCode[] + addresses ShippingAddress[] + cartItems CartItem[] + orders Order[] + reviews Review[] + orderMessageReadStates UserOrderMessageReadState[] + oauthAccounts OAuthAccount[] + notificationPreference NotificationPreference? + notificationLogs NotificationLog[] + } +``` + +- [ ] **Step 2: Reset DB to apply schema change** + +```bash +cd server && npm run db:reset:test +``` + +Expected: "Your database has been reset" or similar. Migration runs and seed applies. + +- [ ] **Step 3: Commit** + +```bash +git add server/prisma/schema.prisma +git commit -m "feat: rename User.name→displayName, add firstName/lastName/gender/avatar" +``` + +--- + +### Task 2: Update server `mapUserForClient` and profile handler in auth.js + +**Files:** +- Modify: `server/src/routes/auth.js:5-15` +- Modify: `server/src/routes/auth.js:116-138` + +- [ ] **Step 1: Update `mapUserForClient` to use `displayName` and add new fields** + +Replace lines 5-15 with: + +```js +function mapUserForClient(user) { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + const userEmail = normalizeEmail(user.email) + return { + id: user.id, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + gender: user.gender, + avatar: user.avatar, + phone: user.phone, + isAdmin: Boolean(adminEmail) && userEmail === adminEmail, + } +} +``` + +- [ ] **Step 2: Update `PATCH /api/me/profile` to use `displayName`** + +Replace lines 114-138 with: + +```js + fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const nameRaw = request.body?.displayName + const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + const phoneRaw = request.body?.phone + const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim() + + if (displayName !== null && displayName.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + if (phone !== null) { + const compact = phone.replace(/[\s()-]/g, '') + if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' }) + if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { + return reply.code(400).send({ error: 'Некорректный телефон' }) + } + } + + const updated = await prisma.user.update({ + where: { id: userId }, + data: { + displayName: displayName && displayName.length ? displayName : null, + phone: phone && phone.length ? phone : null, + }, + }) + return { user: mapUserForClient(updated) } + }) +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/src/routes/auth.js +git commit -m "feat: use displayName in mapUserForClient and profile update" +``` + +--- + +### Task 3: Update admin-users.js — rename `name` → `displayName` + +**Files:** +- Modify: `server/src/routes/api/admin-users.js` + +- [ ] **Step 1: Replace all `name` → `displayName` throughout** + +Replace line 24 (`OR` clause): +``` + OR: [{ email: { contains: q } }, { displayName: { contains: q } }], +``` + +Replace line 35 (select): +``` + displayName: true, +``` + +Replace line 46 (map): +``` + displayName: u.displayName, +``` + +Replace lines 63-64 (create — body field and validation): +``` + const nameRaw = body.displayName + const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + if (displayName !== null && displayName.length > 40) { +``` + +Replace line 79 (create data): +``` + displayName: displayName && displayName.length ? displayName : null, +``` + +Replace line 86 (create response): +``` + displayName: user.displayName, +``` + +Replace lines 120-127 (update): +``` + if (body.displayName !== undefined) { + const nameRaw = body.displayName + const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + if (name !== null && name.length > 40) { + reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + return + } + data.displayName = name && name.length ? name : null + } +``` + +Replace line 134 (update response): +``` + displayName: user.displayName, +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/routes/api/admin-users.js +git commit -m "refactor: rename name→displayName in admin-users" +``` + +--- + +### Task 4: Update admin-reviews.js and review-display.js — rename `name` → `displayName` + +**Files:** +- Modify: `server/src/routes/api/admin-reviews.js:59` +- Modify: `server/src/lib/review-display.js:4` + +- [ ] **Step 1: Update admin-reviews.js line 59** + +Replace: +``` + userName: existing.user?.name || existing.user?.email || '', +``` +With: +``` + userName: existing.user?.displayName || existing.user?.email || '', +``` + +- [ ] **Step 2: Update review-display.js line 4** + +Replace: +``` + const name = typeof user.name === 'string' ? user.name.trim() : '' +``` +With: +``` + const name = typeof user.displayName === 'string' ? user.displayName.trim() : '' +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/src/routes/api/admin-reviews.js server/src/lib/review-display.js +git commit -m "refactor: rename name→displayName in review files" +``` + +--- + +### Task 5: Enrich VK OAuth — save firstName, lastName, gender, avatar + +**Files:** +- Modify: `server/src/routes/oauth-social.js:119-152` + +- [ ] **Step 1: Update VK to fetch `photo_200` instead of `photo_50`, extract sex, and save all new fields** + +Replace lines 119-152 with: + +```js + let firstName = null + let lastName = null + let gender = null + let avatar = null + try { + if (accessTokenVk && vkUserId) { + const u = new URL('https://api.vk.com/method/users.get') + u.searchParams.set('access_token', accessTokenVk) + u.searchParams.set('users_ids', String(vkUserId)) + u.searchParams.set('fields', 'photo_200,sex') + u.searchParams.set('v', '5.199') + const profRes = await fetch(u.toString()) + const prof = await profRes.json() + const u0 = prof?.response?.[0] + if (u0) { + firstName = u0.first_name ?? null + lastName = u0.last_name ?? null + avatar = u0.photo_200 ?? null + if (u0.sex === 1) gender = 'female' + else if (u0.sex === 2) gender = 'male' + } + } + } catch { + // ignore profile extras + } + + const user = await findOrCreateUserFromOAuth({ + provider: 'vk', + providerUserId: String(vkUserId), + accessToken: accessTokenVk ?? null, + suggestedEmail: emailSuggestion, + }) + + const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() + const updateData = {} + if (displayName && !user.displayName) updateData.displayName = displayName + if (firstName) updateData.firstName = firstName + if (lastName) updateData.lastName = lastName + if (gender) updateData.gender = gender + if (avatar) updateData.avatar = avatar + if (Object.keys(updateData).length > 0) { + await prisma.user.update({ where: { id: user.id }, data: updateData }) + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/routes/oauth-social.js +git commit -m "feat: enrich VK OAuth with firstName/lastName/gender/avatar" +``` + +--- + +### Task 6: Enrich Yandex OAuth — save firstName, lastName, gender, avatar + +**Files:** +- Modify: `server/src/routes/oauth-social.js:229-243` + +- [ ] **Step 1: Update Yandex to extract and save all new fields** + +Replace lines 229-243 (from `const user = await findOrCreateUserFromOAuth(...)` to the end of the Yandex callback) with: + +```js + const user = await findOrCreateUserFromOAuth({ + provider: 'yandex', + providerUserId: yaUserId, + accessToken: yaToken, + suggestedEmail: emailGuess || null, + }) + + const updateData = {} + const displayName = [info.first_name, info.last_name].filter(Boolean).join(' ').trim() || info.display_name || info.real_name + if (displayName && !user.displayName) updateData.displayName = displayName + if (info.first_name) updateData.firstName = info.first_name + if (info.last_name) updateData.lastName = info.last_name + if (info.sex === 'male' || info.sex === 'female') updateData.gender = info.sex + if (info.default_avatar_id && !info.is_avatar_empty) { + updateData.avatar = `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200` + } + if (Object.keys(updateData).length > 0) { + await prisma.user.update({ where: { id: user.id }, data: updateData }) + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/routes/oauth-social.js +git commit -m "feat: enrich Yandex OAuth with firstName/lastName/gender/avatar" +``` + +--- + +### Task 7: Update client `AuthUser` type and `UpdateProfileParams` + +**Files:** +- Modify: `client/src/shared/model/auth.ts:6` +- Modify: `client/src/shared/model/auth.ts:61` + +- [ ] **Step 1: Update `AuthUser` type** + +Replace line 6: +``` +export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean } +``` +With: +```ts +export type AuthUser = { + id: string + email: string + displayName?: string | null + firstName?: string | null + lastName?: string | null + gender?: string | null + avatar?: string | null + phone?: string | null + isAdmin?: boolean +} +``` + +- [ ] **Step 2: Update `UpdateProfileParams`** + +Replace line 61: +``` +export type UpdateProfileParams = { name: string | null; phone?: string | null } +``` +With: +```ts +export type UpdateProfileParams = { displayName: string | null; phone?: string | null } +``` + +- [ ] **Step 3: Commit** + +```bash +git add client/src/shared/model/auth.ts +git commit -m "refactor: rename name→displayName in AuthUser type" +``` + +--- + +### Task 8: Update client files — rename `name` → `displayName` + +**Files:** +- Modify: `client/src/pages/auth/ui/AuthPage.tsx:15` +- Modify: `client/src/pages/me/ui/MeLayoutPage.tsx:84` +- Modify: `client/src/features/user/user-menu/ui/UserMenu.tsx:57` +- Modify: `client/src/pages/me/ui/MePage.tsx:41-91` +- Modify: `client/src/pages/me/ui/sections/SettingsPage.tsx:41-98` +- Modify: `client/src/entities/order/api/admin-order-api.ts:40` +- Modify: `client/src/entities/review/api/admin-review-api.ts:10` + +- [ ] **Step 1: Update AuthPage inline type** + +Replace `client/src/pages/auth/ui/AuthPage.tsx` line 15: +``` +type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } } +``` +With: +```ts +type AuthResponse = { token: string; user: { id: string; email: string; displayName?: string | null; phone?: string | null } } +``` + +- [ ] **Step 2: Update MeLayoutPage** + +Replace `client/src/pages/me/ui/MeLayoutPage.tsx` line 84: +``` + {user.name?.trim() || user.email} +``` +With: +``` + {user.displayName?.trim() || user.email} +``` + +- [ ] **Step 3: Update UserMenu** + +Replace `client/src/features/user/user-menu/ui/UserMenu.tsx` line 57: +``` + +``` +With: +``` + +``` + +- [ ] **Step 4: Update MePage (profile page)** + +Replace `client/src/pages/me/ui/MePage.tsx` — the form field name and all related code: + +Line 41 — form type: +``` + const profileForm = useForm<{ displayName: string }>({ +``` + +Line 42 — defaultValues: +``` + defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' }, +``` + +Line 79-83 — TextField: +``` + +``` + +Lines 88-91 — onClick: +``` + onClick={() => { + const raw = profileForm.getValues('displayName') + const name = raw.trim() + updateProfileFx({ displayName: name.length ? name : null }) + }} +``` + +- [ ] **Step 5: Update SettingsPage** + +Replace `client/src/pages/me/ui/sections/SettingsPage.tsx` — same pattern as MePage: + +Line 41 — form type: +``` + const profileForm = useForm<{ displayName: string; phone: string }>({ +``` + +Line 42 — defaultValues: +``` + defaultValues: { displayName: user?.displayName ? String(user.displayName) : '', phone: user?.phone ? String(user.phone) : '' }, +``` + +Lines 79-83 — TextField: +``` + +``` + +Lines 93-98 — onClick: +``` + onClick={() => { + const raw = profileForm.getValues('displayName') + const name = raw.trim() + const phoneRaw = profileForm.getValues('phone') + const phone = phoneRaw.trim() + updateProfileFx({ displayName: name.length ? name : null, phone: phone.length ? phone : null }) + }} +``` + +- [ ] **Step 6: Update admin-order-api.ts type** + +Replace `client/src/entities/order/api/admin-order-api.ts` line 40: +``` + user: { id: string; email: string; name: string | null; phone: string | null } +``` +With: +```ts + user: { id: string; email: string; displayName: string | null; phone: string | null } +``` + +- [ ] **Step 7: Update admin-review-api.ts type** + +Replace `client/src/entities/review/api/admin-review-api.ts` line 10: +``` + user: { id: string; email: string; name: string | null } +``` +With: +```ts + user: { id: string; email: string; displayName: string | null } +``` + +- [ ] **Step 8: Run client lint to verify** + +```bash +cd client && npm run lint +``` + +Expected: no errors (or fix any found). + +- [ ] **Step 9: Commit** + +```bash +git add client/src/pages/auth/ui/AuthPage.tsx client/src/pages/me/ui/MeLayoutPage.tsx client/src/features/user/user-menu/ui/UserMenu.tsx client/src/pages/me/ui/MePage.tsx client/src/pages/me/ui/sections/SettingsPage.tsx client/src/entities/order/api/admin-order-api.ts client/src/entities/review/api/admin-review-api.ts +git commit -m "refactor: rename name→displayName across client" +``` + +--- + +### Task 9: Create OAuth providers config + +**Files:** +- Create: `client/src/features/auth-oauth/lib/oauth-providers.ts` + +- [ ] **Step 1: Create the providers config** + +```ts +import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url' + +export type OAuthProvider = { + id: 'yandex' | 'vk' + label: string + color: string +} + +export const oauthProviders: OAuthProvider[] = [ + { + id: 'yandex', + label: 'Яндекс ID', + color: '#FC3F1D', + }, + { + id: 'vk', + label: 'VK ID', + color: '#0077FF', + }, +] + +export function getOAuthUrl(provider: 'yandex' | 'vk'): string { + return oauthAuthorizeUrl(provider) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add client/src/features/auth-oauth/lib/oauth-providers.ts +git commit -m "feat: add oauth providers config" +``` + +--- + +### Task 10: Create OAuthButtons component + +**Files:** +- Create: `client/src/features/auth-oauth/ui/OAuthButtons.tsx` + +- [ ] **Step 1: Create the OAuthButtons component** + +```tsx +import Stack from '@mui/material/Stack' +import Button from '@mui/material/Button' +import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers' + +export function OAuthButtons() { + return ( + + {oauthProviders.map((p) => ( + + ))} + + ) +} +``` + +- [ ] **Step 2: Create barrel export** + +Create `client/src/features/auth-oauth/index.ts`: + +```ts +export { OAuthButtons } from './ui/OAuthButtons' +``` + +- [ ] **Step 3: Commit** + +```bash +git add client/src/features/auth-oauth/ +git commit -m "feat: add OAuthButtons component" +``` + +--- + +### Task 11: Integrate OAuthButtons into AuthPage + +**Files:** +- Modify: `client/src/pages/auth/ui/AuthPage.tsx` + +- [ ] **Step 1: Add import and component after the email-code form** + +Add import at top (after existing imports): +```ts +import { OAuthButtons } from '@/features/auth-oauth' +``` + +Add MUI Divider import (add to existing MUI imports): +``` +import Divider from '@mui/material/Divider' +``` + +After the closing `` on line 110, before the closing `` on line 111, add: + +```tsx + или + + +``` + +So the structure becomes: + +```tsx + + {/* ... existing email/code form ... */} + + {/* ... buttons ... */} + + + + + или + + + +``` + +- [ ] **Step 2: Run client lint** + +```bash +cd client && npm run lint +``` + +- [ ] **Step 3: Commit** + +```bash +git add client/src/pages/auth/ui/AuthPage.tsx +git commit -m "feat: add OAuth buttons to AuthPage" +``` + +--- + +### Task 12: Update server .env.example + +**Files:** +- Modify: `server/.env.example:28-30` + +- [ ] **Step 1: Add scope documentation** + +Replace lines 28-30: +``` +# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback +YANDEX_CLIENT_ID= +YANDEX_CLIENT_SECRET= +``` +With: +```env +# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback +# Scopes: login:email login:info +YANDEX_CLIENT_ID= +YANDEX_CLIENT_SECRET= +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/.env.example +git commit -m "docs: add Yandex OAuth scopes to .env.example" +``` + +--- + +### Task 13: Write server tests for OAuth profile enrichment + +**Files:** +- Create: `server/src/routes/__tests__/oauth-social.test.js` + +- [ ] **Step 1: Create test file** + +```js +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { prisma } from '../../lib/prisma.js' + +describe('OAuth — findOrCreateUserFromOAuth (indirect via DB checks)', () => { + it('stores displayName, firstName, lastName, gender, avatar fields on User model', async () => { + // Verify new fields exist on the schema by creating a user with them + const user = await prisma.user.create({ + data: { + email: 'test-oauth@example.com', + displayName: 'Test User', + firstName: 'Test', + lastName: 'User', + gender: 'male', + avatar: 'https://example.com/avatar.jpg', + }, + }) + + expect(user.displayName).toBe('Test User') + expect(user.firstName).toBe('Test') + expect(user.lastName).toBe('User') + expect(user.gender).toBe('male') + expect(user.avatar).toBe('https://example.com/avatar.jpg') + + // Cleanup + await prisma.user.delete({ where: { id: user.id } }) + }) + + it('allows nullable fields', async () => { + const user = await prisma.user.create({ + data: { + email: 'test-oauth-null@example.com', + }, + }) + + expect(user.displayName).toBeNull() + expect(user.firstName).toBeNull() + expect(user.lastName).toBeNull() + expect(user.gender).toBeNull() + expect(user.avatar).toBeNull() + + await prisma.user.delete({ where: { id: user.id } }) + }) +}) +``` + +- [ ] **Step 2: Run server tests** + +```bash +cd server && npm test +``` + +Expected: 2 passing tests. + +- [ ] **Step 3: Commit** + +```bash +git add server/src/routes/__tests__/oauth-social.test.js +git commit -m "test: OAuth user model fields" +``` + +--- + +### Task 14: Write client test for OAuthButtons + +**Files:** +- Create: `client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx` + +- [ ] **Step 1: Create test file** + +```tsx +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { OAuthButtons } from '../ui/OAuthButtons' + +describe('OAuthButtons', () => { + it('renders Yandex and VK buttons', () => { + render() + expect(screen.getByText('Войти через Яндекс ID')).toBeDefined() + expect(screen.getByText('Войти через VK ID')).toBeDefined() + }) + + it('buttons have correct href', () => { + render() + const yaBtn = screen.getByText('Войти через Яндекс ID').closest('a') + const vkBtn = screen.getByText('Войти через VK ID').closest('a') + expect(yaBtn?.getAttribute('href')).toContain('/auth/oauth/yandex') + expect(vkBtn?.getAttribute('href')).toContain('/auth/oauth/vk') + }) +}) +``` + +- [ ] **Step 2: Run client tests** + +```bash +cd client && npm test +``` + +Expected: 2 passing tests. + +- [ ] **Step 3: Commit** + +```bash +git add client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx +git commit -m "test: OAuthButtons component" +``` + +--- + +### Task 15: Final verification — full build and lint + +**Files:** none (verification only) + +- [ ] **Step 1: Run server lint** + +```bash +cd server && npm run lint +``` + +Expected: no errors. + +- [ ] **Step 2: Run server tests** + +```bash +cd server && npm test +``` + +Expected: all tests pass. + +- [ ] **Step 3: Run client lint + format check** + +```bash +cd client && npm run lint && npm run format:check +``` + +Expected: no errors. + +- [ ] **Step 4: Run client tests** + +```bash +cd client && npm test +``` + +Expected: all tests pass. + +- [ ] **Step 5: Run client build (full typecheck)** + +```bash +cd client && npm run build +``` + +Expected: build succeeds with no TypeScript errors. + +- [ ] **Step 6: Final commit (if any fixups were needed)** + +```bash +git add -A && git commit -m "chore: final verification fixes" || echo "No fixups needed" +```