Merge branch 'autorization'
This commit is contained in:
@@ -37,7 +37,7 @@ export type AdminOrderDetailResponse = {
|
|||||||
comment: string | null
|
comment: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
user: { id: string; email: string; name: string | null; phone: string | null }
|
user: { id: string; email: string; displayName: string | null; phone: string | null }
|
||||||
items: Array<{
|
items: Array<{
|
||||||
id: string
|
id: string
|
||||||
productId: string
|
productId: string
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type AdminReview = {
|
|||||||
status: string
|
status: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
moderatedAt: string | null
|
moderatedAt: string | null
|
||||||
user: { id: string; email: string; name: string | null }
|
user: { id: string; email: string; displayName: string | null }
|
||||||
product: { id: string; title: string }
|
product: { id: string; title: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { OAuthButtons } from '../ui/OAuthButtons'
|
||||||
|
|
||||||
|
describe('OAuthButtons', () => {
|
||||||
|
it('renders Yandex and VK buttons', () => {
|
||||||
|
render(<OAuthButtons />)
|
||||||
|
expect(screen.getByText('Войти через Яндекс ID')).toBeDefined()
|
||||||
|
expect(screen.getByText('Войти через VK ID')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buttons have correct href', () => {
|
||||||
|
render(<OAuthButtons />)
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OAuthButtons } from './ui/OAuthButtons'
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers'
|
||||||
|
|
||||||
|
export function OAuthButtons() {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ justifyContent: 'center' }}>
|
||||||
|
{oauthProviders.map((p) => (
|
||||||
|
<Button
|
||||||
|
key={p.id}
|
||||||
|
variant="outlined"
|
||||||
|
href={getOAuthUrl(p.id)}
|
||||||
|
sx={{
|
||||||
|
borderColor: p.color,
|
||||||
|
color: p.color,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: p.color,
|
||||||
|
bgcolor: `${p.color}14`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войти через {p.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) {
|
|||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<MenuItem onClick={() => go('/me')}>
|
<MenuItem onClick={() => go('/me')}>
|
||||||
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
<ListItemText primary={(user.displayName && user.displayName.trim()) || user.email} secondary="Профиль" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleLogout}>Выход</MenuItem>
|
<MenuItem onClick={handleLogout}>Выход</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
|
import Divider from '@mui/material/Divider'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
@@ -9,10 +10,14 @@ import { useMutation } from '@tanstack/react-query'
|
|||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { OAuthButtons } from '@/features/auth-oauth'
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { apiClient } from '@/shared/api/client'
|
||||||
import { $user, tokenSet } from '@/shared/model/auth'
|
import { $user, tokenSet } from '@/shared/model/auth'
|
||||||
|
|
||||||
type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } }
|
type AuthResponse = {
|
||||||
|
token: string
|
||||||
|
user: { id: string; email: string; displayName?: string | null; phone?: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
function getApiErrorMessage(err: unknown): string | null {
|
function getApiErrorMessage(err: unknown): string | null {
|
||||||
if (!err || typeof err !== 'object') return null
|
if (!err || typeof err !== 'object') return null
|
||||||
@@ -108,6 +113,12 @@ export function AuthPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Stack sx={{ maxWidth: 520 }}>
|
||||||
|
<Divider sx={{ my: 2 }}>или</Divider>
|
||||||
|
|
||||||
|
<OAuthButtons />
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function MeLayoutPage() {
|
|||||||
Кабинет
|
Кабинет
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{user.name?.trim() || user.email}
|
{user.displayName?.trim() || user.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ my: 1 }} />
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function MePage() {
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
})
|
})
|
||||||
|
|
||||||
const profileForm = useForm<{ name: string }>({
|
const profileForm = useForm<{ displayName: string }>({
|
||||||
defaultValues: { name: user?.name ? String(user.name) : '' },
|
defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' },
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,15 +80,15 @@ export function MePage() {
|
|||||||
label="Имя или ник"
|
label="Имя или ник"
|
||||||
helperText="До 40 символов"
|
helperText="До 40 символов"
|
||||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||||
{...profileForm.register('name')}
|
{...profileForm.register('displayName')}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={pendingProfile}
|
disabled={pendingProfile}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const raw = profileForm.getValues('name')
|
const raw = profileForm.getValues('displayName')
|
||||||
const name = raw.trim()
|
const name = raw.trim()
|
||||||
updateProfileFx({ name: name.length ? name : null })
|
updateProfileFx({ displayName: name.length ? name : null })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Сохранить имя
|
Сохранить имя
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ export function SettingsPage() {
|
|||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
})
|
})
|
||||||
|
|
||||||
const profileForm = useForm<{ name: string; phone: string }>({
|
const profileForm = useForm<{ displayName: string; phone: string }>({
|
||||||
defaultValues: { name: user?.name ? String(user.name) : '', phone: user?.phone ? String(user.phone) : '' },
|
defaultValues: {
|
||||||
|
displayName: user?.displayName ? String(user.displayName) : '',
|
||||||
|
phone: user?.phone ? String(user.phone) : '',
|
||||||
|
},
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,7 +83,7 @@ export function SettingsPage() {
|
|||||||
label="Имя или ник"
|
label="Имя или ник"
|
||||||
helperText="До 40 символов"
|
helperText="До 40 символов"
|
||||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||||
{...profileForm.register('name')}
|
{...profileForm.register('displayName')}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Телефон"
|
label="Телефон"
|
||||||
@@ -91,11 +94,11 @@ export function SettingsPage() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={pendingProfile}
|
disabled={pendingProfile}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const raw = profileForm.getValues('name')
|
const raw = profileForm.getValues('displayName')
|
||||||
const name = raw.trim()
|
const name = raw.trim()
|
||||||
const phoneRaw = profileForm.getValues('phone')
|
const phoneRaw = profileForm.getValues('phone')
|
||||||
const phone = phoneRaw.trim()
|
const phone = phoneRaw.trim()
|
||||||
updateProfileFx({ name: name.length ? name : null, phone: phone.length ? phone : null })
|
updateProfileFx({ displayName: name.length ? name : null, phone: phone.length ? phone : null })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Сохранить
|
Сохранить
|
||||||
|
|||||||
@@ -3,7 +3,17 @@ import { apiClient } from '@/shared/api/client'
|
|||||||
import { createErrorStore } from '@/shared/lib/create-error-store'
|
import { createErrorStore } from '@/shared/lib/create-error-store'
|
||||||
import { persistToken } from '@/shared/lib/persist-token'
|
import { persistToken } from '@/shared/lib/persist-token'
|
||||||
|
|
||||||
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean }
|
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
|
||||||
|
}
|
||||||
|
|
||||||
export const tokenSet = createEvent<string | null>()
|
export const tokenSet = createEvent<string | null>()
|
||||||
export const logout = createEvent()
|
export const logout = createEvent()
|
||||||
@@ -58,7 +68,7 @@ export const verifyEmailChangeFx = createEffect(async (params: { newEmail: strin
|
|||||||
|
|
||||||
// ----- Profile update -----
|
// ----- Profile update -----
|
||||||
|
|
||||||
export type UpdateProfileParams = { name: string | null; phone?: string | null }
|
export type UpdateProfileParams = { displayName: string | null; phone?: string | null }
|
||||||
|
|
||||||
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
|
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
|
||||||
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
|
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
```
|
||||||
|
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
||||||
|
```
|
||||||
|
With:
|
||||||
|
```
|
||||||
|
<ListItemText primary={(user.displayName && user.displayName.trim()) || user.email} secondary="Профиль" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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:
|
||||||
|
```
|
||||||
|
<TextField
|
||||||
|
label="Имя или ник"
|
||||||
|
helperText="До 40 символов"
|
||||||
|
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||||
|
{...profileForm.register('displayName')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
```
|
||||||
|
<TextField
|
||||||
|
label="Имя или ник"
|
||||||
|
helperText="До 40 символов"
|
||||||
|
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||||
|
{...profileForm.register('displayName')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="center">
|
||||||
|
{oauthProviders.map((p) => (
|
||||||
|
<Button
|
||||||
|
key={p.id}
|
||||||
|
variant="outlined"
|
||||||
|
href={getOAuthUrl(p.id)}
|
||||||
|
sx={{
|
||||||
|
borderColor: p.color,
|
||||||
|
color: p.color,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: p.color,
|
||||||
|
bgcolor: `${p.color}14`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войти через {p.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 `</Stack>` on line 110, before the closing `</Box>` on line 111, add:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Divider sx={{ my: 2 }}>или</Divider>
|
||||||
|
|
||||||
|
<OAuthButtons />
|
||||||
|
```
|
||||||
|
|
||||||
|
So the structure becomes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack spacing={2} sx={{ maxWidth: 520 }}>
|
||||||
|
{/* ... existing email/code form ... */}
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
|
{/* ... buttons ... */}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack sx={{ maxWidth: 520 }}>
|
||||||
|
<Divider sx={{ my: 2 }}>или</Divider>
|
||||||
|
|
||||||
|
<OAuthButtons />
|
||||||
|
</Stack>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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(<OAuthButtons />)
|
||||||
|
expect(screen.getByText('Войти через Яндекс ID')).toBeDefined()
|
||||||
|
expect(screen.getByText('Войти через VK ID')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buttons have correct href', () => {
|
||||||
|
render(<OAuthButtons />)
|
||||||
|
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"
|
||||||
|
```
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Yandex ID + VK ID OAuth — Design
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Подключить авторизацию через Яндекс ID и VK ID на клиенте (серверная часть OAuth уже реализована). Добавить поля профиля: имя, фамилия, пол, аватар. Админ продолжает входить только через email/код.
|
||||||
|
|
||||||
|
## Объём
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
- Переименовать `User.name` → `User.displayName`
|
||||||
|
- Добавить поля: `firstName String?`, `lastName String?`, `gender String?`, `avatar String?`
|
||||||
|
- Сбросить БД (`prisma migrate reset --force`), прода нет
|
||||||
|
|
||||||
|
### Сервер
|
||||||
|
|
||||||
|
1. **`server/prisma/schema.prisma`** — обновить модель User
|
||||||
|
2. **`server/src/routes/oauth-social.js`** — обновить `findOrCreateUserFromOAuth()`:
|
||||||
|
- Яндекс: сохранять `firstName`, `lastName`, `gender` (`sex`), `avatar` (`https://avatars.yandex.net/get-yapic/{default_avatar_id}/islands-200`), `displayName` ← `real_name` или `display_name`
|
||||||
|
- VK: сохранять `firstName` (`first_name`), `lastName` (`last_name`), `gender` (`sex`: 1→female, 2→male), `avatar` (`photo_200`), `displayName` ← `first_name + ' ' + last_name`
|
||||||
|
- Gender: если провайдер не вернул — оставлять `null`
|
||||||
|
3. **`server/src/routes/auth.js`** — обновить `mapUserForClient()`: добавить новые поля в ответ `/api/me`; переименовать `name` → `displayName`
|
||||||
|
4. **`server/src/**/*.js`** — найти и заменить все использования `user.name` на `user.displayName`
|
||||||
|
5. **`server/.env.example`** — документировать redirect URI для Яндекс и VK
|
||||||
|
|
||||||
|
### Клиент
|
||||||
|
|
||||||
|
1. **`client/src/shared/model/auth.ts`** — обновить тип `AuthUser`, добавить `displayName`, `firstName`, `lastName`, `gender`, `avatar`; убрать `name`
|
||||||
|
2. **`client/src/features/auth-oauth/`** — новая FSD-фича:
|
||||||
|
- `lib/oauth-providers.ts` — конфигурация: `{ id, label, icon, color }` для yandex и vk
|
||||||
|
- `ui/OAuthButtons.tsx` — компонент с двумя кнопками (Stack + Button variant="outlined"), каждая редиректит на `/api/auth/oauth/{provider}`
|
||||||
|
- `index.ts` — barrel экспорт
|
||||||
|
3. **`client/src/pages/auth/ui/AuthPage.tsx`** — добавить `<OAuthButtons />` после формы email-кода, разделив Divider'ом с текстом «или»
|
||||||
|
4. **`client/src/**/*.tsx`** — найти и заменить все использования `user.name` → `user.displayName`
|
||||||
|
|
||||||
|
### ENV
|
||||||
|
|
||||||
|
Переменные в `server/.env` (из примера):
|
||||||
|
|
||||||
|
```
|
||||||
|
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
||||||
|
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
|
||||||
|
YANDEX_CLIENT_ID=<значение>
|
||||||
|
YANDEX_CLIENT_SECRET=<значение>
|
||||||
|
VK_CLIENT_ID=<значение>
|
||||||
|
VK_CLIENT_SECRET=<значение>
|
||||||
|
```
|
||||||
|
|
||||||
|
Redirect URI для настройки в кабинетах провайдеров:
|
||||||
|
- Яндекс (локально): `http://127.0.0.1:3333/api/auth/oauth/yandex/callback`
|
||||||
|
- VK (локально): `http://127.0.0.1:3333/api/auth/oauth/vk/callback`
|
||||||
|
- Яндекс (прод): `https://любимыйкреатив.рф/api/auth/oauth/yandex/callback`
|
||||||
|
- VK (прод): `https://любимыйкреатив.рф/api/auth/oauth/vk/callback`
|
||||||
|
|
||||||
|
## Структура фичи `features/auth-oauth/`
|
||||||
|
|
||||||
|
```
|
||||||
|
features/auth-oauth/
|
||||||
|
index.ts — barrel: export { OAuthButtons }
|
||||||
|
ui/
|
||||||
|
OAuthButtons.tsx — Stack из 2 кнопок (Яндекс, VK)
|
||||||
|
lib/
|
||||||
|
oauth-providers.ts — массив провайдеров: { id, label, icon, color }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data flow (OAuth)
|
||||||
|
|
||||||
|
```
|
||||||
|
Клиент: кнопка «Войти через Яндекс/VK»
|
||||||
|
→ редирект на /api/auth/oauth/{yandex|vk}
|
||||||
|
Сервер: формирует state JWT, редиректит на Яндекс/VK
|
||||||
|
→ пользователь авторизуется у провайдера
|
||||||
|
→ провайдер редиректит на /api/auth/oauth/{yandex|vk}/callback
|
||||||
|
Сервер: обменивает code на токен → получает профиль → findOrCreateUserFromOAuth()
|
||||||
|
→ генерирует JWT → редиректит на {CLIENT_PUBLIC_URL}/auth/callback?token=<jwt>
|
||||||
|
Клиент: AuthCallbackPage читает token → сохраняет в localStorage → редирект на /
|
||||||
|
```
|
||||||
|
|
||||||
|
## Не входит в scope
|
||||||
|
|
||||||
|
- Отображение аватара в хедере/UserMenu (будет отдельно)
|
||||||
|
- Страница профиля с новыми полями (будет отдельно)
|
||||||
|
- OAuth для админа (админ только email/код)
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- `gender` — nullable, если провайдер не вернул пол
|
||||||
|
- VK: `sex: 1` = female, `sex: 2` = male → нормализуем в `female` / `male`
|
||||||
|
- Яндекс: avatar — конструируем URL из `default_avatar_id`, поле `is_avatar_empty` подскажет, загружен ли аватар
|
||||||
|
- Яндекс scopes: `login:email login:info`
|
||||||
|
- VK scopes: `email`
|
||||||
|
- OAuth state — JWT с `expiresIn: 15m`
|
||||||
@@ -26,6 +26,7 @@ VK_CLIENT_ID=
|
|||||||
VK_CLIENT_SECRET=
|
VK_CLIENT_SECRET=
|
||||||
|
|
||||||
# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
|
# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
|
||||||
|
# Scopes: login:email login:info
|
||||||
YANDEX_CLIENT_ID=
|
YANDEX_CLIENT_ID=
|
||||||
YANDEX_CLIENT_SECRET=
|
YANDEX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `name` on the `User` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"gender" TEXT,
|
||||||
|
"avatar" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("createdAt", "email", "id", "passwordHash", "phone", "updatedAt") SELECT "createdAt", "email", "id", "passwordHash", "phone", "updatedAt" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
Binary file not shown.
@@ -77,7 +77,11 @@ model CatalogSliderSlide {
|
|||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
displayName String?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
gender String?
|
||||||
|
avatar String?
|
||||||
phone String?
|
phone String?
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Публичное отображение автора отзыва (без «голого» email). */
|
/** Публичное отображение автора отзыва (без «голого» email). */
|
||||||
export function publicReviewAuthorDisplay(user) {
|
export function publicReviewAuthorDisplay(user) {
|
||||||
if (!user || typeof user !== 'object') return 'Покупатель'
|
if (!user || typeof user !== 'object') return 'Покупатель'
|
||||||
const name = typeof user.name === 'string' ? user.name.trim() : ''
|
const name = typeof user.displayName === 'string' ? user.displayName.trim() : ''
|
||||||
if (name) return name
|
if (name) return name
|
||||||
const email = typeof user.email === 'string' ? user.email.trim() : ''
|
const email = typeof user.email === 'string' ? user.email.trim() : ''
|
||||||
const at = email.indexOf('@')
|
const at = email.indexOf('@')
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
describe('OAuth — User model fields', () => {
|
||||||
|
it('stores displayName, firstName, lastName, gender, avatar fields on User model', async () => {
|
||||||
|
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')
|
||||||
|
|
||||||
|
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 } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -73,7 +73,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
|||||||
const order = await prisma.order.findUnique({
|
const order = await prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, email: true, name: true, phone: true } },
|
user: { select: { id: true, email: true, displayName: true, phone: true } },
|
||||||
items: true,
|
items: true,
|
||||||
messages: { orderBy: { createdAt: 'asc' } },
|
messages: { orderBy: { createdAt: 'asc' } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function registerAdminReviewRoutes(fastify) {
|
|||||||
const items = await prisma.review.findMany({
|
const items = await prisma.review.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, email: true, name: true } },
|
user: { select: { id: true, email: true, displayName: true } },
|
||||||
product: { select: { id: true, title: true } },
|
product: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -40,7 +40,7 @@ export async function registerAdminReviewRoutes(fastify) {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
product: { select: { title: true } },
|
product: { select: { title: true } },
|
||||||
user: { select: { name: true, email: true } },
|
user: { select: { displayName: true, email: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
|
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
|
||||||
@@ -56,7 +56,7 @@ export async function registerAdminReviewRoutes(fastify) {
|
|||||||
rating: updated.rating,
|
rating: updated.rating,
|
||||||
text: updated.text || '',
|
text: updated.text || '',
|
||||||
productTitle: existing.product?.title || '',
|
productTitle: existing.product?.title || '',
|
||||||
userName: existing.user?.name || existing.user?.email || '',
|
userName: existing.user?.displayName || existing.user?.email || '',
|
||||||
reviewId: updated.id,
|
reviewId: updated.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
|
|
||||||
const where = q
|
const where = q
|
||||||
? {
|
? {
|
||||||
OR: [{ email: { contains: q } }, { name: { contains: q } }],
|
OR: [{ email: { contains: q } }, { displayName: { contains: q } }],
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
displayName: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
@@ -43,7 +43,7 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
const items = users.map((u) => ({
|
const items = users.map((u) => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
name: u.name,
|
displayName: u.displayName,
|
||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
updatedAt: u.updatedAt,
|
updatedAt: u.updatedAt,
|
||||||
}))
|
}))
|
||||||
@@ -60,9 +60,9 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameRaw = body.name
|
const nameRaw = body.displayName
|
||||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||||
if (name !== null && name.length > 40) {
|
if (displayName !== null && displayName.length > 40) {
|
||||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -76,14 +76,14 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
name: name && name.length ? name : null,
|
displayName: displayName && displayName.length ? displayName : null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
reply.code(201).send({
|
reply.code(201).send({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
displayName: user.displayName,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
updatedAt: user.updatedAt,
|
updatedAt: user.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -117,21 +117,21 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.name !== undefined) {
|
if (body.displayName !== undefined) {
|
||||||
const nameRaw = body.name
|
const nameRaw = body.displayName
|
||||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||||
if (name !== null && name.length > 40) {
|
if (name !== null && name.length > 40) {
|
||||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.name = name && name.length ? name : null
|
data.displayName = name && name.length ? name : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.update({ where: { id }, data })
|
const user = await prisma.user.update({ where: { id }, data })
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
displayName: user.displayName,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
updatedAt: user.updatedAt,
|
updatedAt: user.updatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function registerPublicReviewRoutes(fastify) {
|
|||||||
const rows = await prisma.review.findMany({
|
const rows = await prisma.review.findMany({
|
||||||
where: { status: 'approved', product: { published: true } },
|
where: { status: 'approved', product: { published: true } },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { email: true, name: true } },
|
user: { select: { email: true, displayName: true } },
|
||||||
product: { select: { id: true, title: true } },
|
product: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -80,7 +80,7 @@ export async function registerPublicReviewRoutes(fastify) {
|
|||||||
const total = await prisma.review.count({ where })
|
const total = await prisma.review.count({ where })
|
||||||
const rawItems = await prisma.review.findMany({
|
const rawItems = await prisma.review.findMany({
|
||||||
where,
|
where,
|
||||||
include: { user: { select: { email: true, name: true } } },
|
include: { user: { select: { email: true, displayName: true } } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ function mapUserForClient(user) {
|
|||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
displayName: user.displayName,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
gender: user.gender,
|
||||||
|
avatar: user.avatar,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
|
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
|
||||||
}
|
}
|
||||||
@@ -113,12 +117,13 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
|
|
||||||
fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const nameRaw = request.body?.name
|
const nameRaw = request.body?.displayName
|
||||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||||
const phoneRaw = request.body?.phone
|
const phoneRaw = request.body?.phone
|
||||||
const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim()
|
const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim()
|
||||||
|
|
||||||
if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
if (displayName !== null && displayName.length > 40)
|
||||||
|
return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||||
if (phone !== null) {
|
if (phone !== null) {
|
||||||
const compact = phone.replace(/[\s()-]/g, '')
|
const compact = phone.replace(/[\s()-]/g, '')
|
||||||
if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' })
|
if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' })
|
||||||
@@ -130,7 +135,7 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
const updated = await prisma.user.update({
|
const updated = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
name: name && name.length ? name : null,
|
displayName: displayName && displayName.length ? displayName : null,
|
||||||
phone: phone && phone.length ? phone : null,
|
phone: phone && phone.length ? phone : null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ async function issueUserJwt(fastify, userId, email) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) {
|
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) {
|
||||||
const existingLink = await prisma.oauthAccount.findUnique({
|
const existingLink = await prisma.oAuthAccount.findUnique({
|
||||||
where: { provider_providerUserId: { provider, providerUserId } },
|
where: { provider_providerUserId: { provider, providerUserId } },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
})
|
})
|
||||||
if (existingLink?.user) {
|
if (existingLink?.user) {
|
||||||
if (accessToken !== undefined) {
|
if (accessToken !== undefined) {
|
||||||
await prisma.oauthAccount.update({
|
await prisma.oAuthAccount.update({
|
||||||
where: { provider_providerUserId: { provider, providerUserId } },
|
where: { provider_providerUserId: { provider, providerUserId } },
|
||||||
data: { accessToken },
|
data: { accessToken },
|
||||||
})
|
})
|
||||||
@@ -36,7 +36,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
|
|||||||
const norm = trimmed ? normalizeEmail(trimmed) : null
|
const norm = trimmed ? normalizeEmail(trimmed) : null
|
||||||
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
|
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
|
||||||
if (user) {
|
if (user) {
|
||||||
await prisma.oauthAccount.create({
|
await prisma.oAuthAccount.create({
|
||||||
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
||||||
})
|
})
|
||||||
return user
|
return user
|
||||||
@@ -49,7 +49,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
|
|||||||
email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local`
|
email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local`
|
||||||
}
|
}
|
||||||
user = await prisma.user.create({ data: { email } })
|
user = await prisma.user.create({ data: { email } })
|
||||||
await prisma.oauthAccount.create({
|
await prisma.oAuthAccount.create({
|
||||||
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
||||||
})
|
})
|
||||||
return user
|
return user
|
||||||
@@ -118,12 +118,14 @@ export async function registerOAuthSocialRoutes(fastify) {
|
|||||||
|
|
||||||
let firstName = null
|
let firstName = null
|
||||||
let lastName = null
|
let lastName = null
|
||||||
|
let gender = null
|
||||||
|
let avatar = null
|
||||||
try {
|
try {
|
||||||
if (accessTokenVk && vkUserId) {
|
if (accessTokenVk && vkUserId) {
|
||||||
const u = new URL('https://api.vk.com/method/users.get')
|
const u = new URL('https://api.vk.com/method/users.get')
|
||||||
u.searchParams.set('access_token', accessTokenVk)
|
u.searchParams.set('access_token', accessTokenVk)
|
||||||
u.searchParams.set('users_ids', String(vkUserId))
|
u.searchParams.set('users_ids', String(vkUserId))
|
||||||
u.searchParams.set('fields', 'photo_50')
|
u.searchParams.set('fields', 'photo_200,sex')
|
||||||
u.searchParams.set('v', '5.199')
|
u.searchParams.set('v', '5.199')
|
||||||
const profRes = await fetch(u.toString())
|
const profRes = await fetch(u.toString())
|
||||||
const prof = await profRes.json()
|
const prof = await profRes.json()
|
||||||
@@ -131,6 +133,9 @@ export async function registerOAuthSocialRoutes(fastify) {
|
|||||||
if (u0) {
|
if (u0) {
|
||||||
firstName = u0.first_name ?? null
|
firstName = u0.first_name ?? null
|
||||||
lastName = u0.last_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 {
|
} catch {
|
||||||
@@ -144,11 +149,15 @@ export async function registerOAuthSocialRoutes(fastify) {
|
|||||||
suggestedEmail: emailSuggestion,
|
suggestedEmail: emailSuggestion,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (firstName || lastName) {
|
const displayName = [firstName, lastName].filter(Boolean).join(' ').trim()
|
||||||
const name = [firstName, lastName].filter(Boolean).join(' ').trim()
|
const updateData = {}
|
||||||
if (name && !user.name) {
|
if (displayName && !user.displayName) updateData.displayName = displayName
|
||||||
await prisma.user.update({ where: { id: user.id }, data: { name } })
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await issueUserJwt(fastify, user.id, user.email)
|
const token = await issueUserJwt(fastify, user.id, user.email)
|
||||||
@@ -233,9 +242,18 @@ export async function registerOAuthSocialRoutes(fastify) {
|
|||||||
suggestedEmail: emailGuess || null,
|
suggestedEmail: emailGuess || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const dn = `${info.first_name ?? ''} ${info.last_name ?? ''}`.trim()
|
const updateData = {}
|
||||||
if (dn && !user.name) {
|
const displayName =
|
||||||
await prisma.user.update({ where: { id: user.id }, data: { name: dn } })
|
[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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await issueUserJwt(fastify, user.id, user.email)
|
const token = await issueUserJwt(fastify, user.id, user.email)
|
||||||
|
|||||||
Reference in New Issue
Block a user