docs: add VK no-email fix design spec
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
# VK OAuth без email — Design
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
VK ID не всегда возвращает email (необязательное поле). Текущий код требует email на трёх уровнях:
|
||||||
|
1. Callback (`oauth-social.js:209`) — `if (!emailSuggestion) return oauthErrorRedirect(...)`
|
||||||
|
2. `findOrCreateUserFromOAuth` (`oauth-social.js:72,87`) — `if (!norm) return null`
|
||||||
|
3. Схема БД — `email String @unique` (NOT NULL)
|
||||||
|
|
||||||
|
Результат: пользователь, у которого VK не отдал email, видит ошибку `no_email` и не может войти.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Три изменения:
|
||||||
|
|
||||||
|
1. **Новый пользователь без email** — генерировать синтетический email `vk_<providerUserId>@vk.local`
|
||||||
|
2. **Привязка VK к существующему аккаунту (link)** — не требовать email от VK
|
||||||
|
3. **Смена email в профиле** — дать пользователю возможность сменить синтетический email на настоящий, с верификацией
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Часть 1: OAuth flow (сервер)
|
||||||
|
|
||||||
|
### `server/src/routes/oauth-social.js`
|
||||||
|
|
||||||
|
**`findOrCreateUserFromOAuth`** (стр. 53-104):
|
||||||
|
|
||||||
|
- **Режим link** (стр. 71-77): убрать `if (!norm) return null`. Если `linkToUserId` передан — email не нужен, создаём `OAuthAccount` и возвращаем пользователя.
|
||||||
|
- **Новый пользователь без email** (стр. 87): вместо `if (!norm) return null` — если `norm` отсутствует, генерируем `vk_<providerUserId>@vk.local` и создаём пользователя с `displayName = 'Пользователь'`.
|
||||||
|
|
||||||
|
**VK callback** (стр. 206-209): убрать строку `if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email')`.
|
||||||
|
|
||||||
|
**Yandex callback** — без изменений (Яндекс всегда возвращает email).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Часть 2: Смена email с верификацией
|
||||||
|
|
||||||
|
### Схема БД — новая модель `PendingEmail`
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model PendingEmail {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
email String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Миграция
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npx prisma migrate dev --name pending_email
|
||||||
|
```
|
||||||
|
|
||||||
|
### Серверные роуты
|
||||||
|
|
||||||
|
Новые роуты в `server/src/routes/auth-session.js` (рядом с `/api/me` и `/api/me/auth-methods`):
|
||||||
|
|
||||||
|
**`PATCH /api/me/email`** (requireAuth):
|
||||||
|
|
||||||
|
- Тело: `{ email: string }`
|
||||||
|
- Валидация: нормализовать через `normalizeEmail()`, проверить формат
|
||||||
|
- Проверить, что email не занят (`findUnique({ email })`) → 409 Conflict
|
||||||
|
- Удалить предыдущие `PendingEmail` для этого пользователя
|
||||||
|
- Создать `PendingEmail` с `token = crypto.randomUUID()`, `expiresAt = now + 24h`
|
||||||
|
- Ответ: `{ verificationUrl: '/api/me/verify-email?token=<uuid>' }` (отправка email не реализуем, токен возвращаем в ответе API)
|
||||||
|
|
||||||
|
**`GET /api/me/verify-email`** (без авторизации, только по токену):
|
||||||
|
|
||||||
|
- Искать `PendingEmail` по токену, проверить `expiresAt > now`
|
||||||
|
- Обновить `User.email`, удалить `PendingEmail`
|
||||||
|
- Редирект: `{CLIENT_PUBLIC_URL}/me?emailVerified=1`
|
||||||
|
|
||||||
|
### Клиент
|
||||||
|
|
||||||
|
**`client/src/shared/model/auth.ts`** — добавить эффекты:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const requestEmailChangeFx = createEffect(async (email: string) => {
|
||||||
|
const { data } = await apiClient.patch<{ verificationUrl: string }>('me/email', { email })
|
||||||
|
return data.verificationUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
export const verifyEmailFx = createEffect(async (token: string) => {
|
||||||
|
window.location.href = `/api/me/verify-email?token=${token}`
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**`AuthMethodsSection.tsx`** — добавить секцию смены email:
|
||||||
|
|
||||||
|
- Текстовое поле (email) + кнопка «Сменить email»
|
||||||
|
- После успешного запроса — показать кнопку «Подтвердить email» (переход по `verificationUrl`)
|
||||||
|
- Ошибки: неверный формат, email занят
|
||||||
|
- После успешной верификации — обновить `$user` (подгрузить через `meFx` заново)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура изменений
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
prisma/schema.prisma — модель PendingEmail
|
||||||
|
prisma/migrations/ — миграция (авто)
|
||||||
|
src/routes/oauth-social.js — findOrCreateUserFromOAuth + VK callback fix
|
||||||
|
src/routes/auth-session.js — PATCH /api/me/email, GET /api/me/verify-email
|
||||||
|
__tests__/ — тесты на новый flow
|
||||||
|
|
||||||
|
client/
|
||||||
|
src/shared/model/auth.ts — requestEmailChangeFx, verifyEmailFx
|
||||||
|
src/pages/me/ui/sections/
|
||||||
|
AuthMethodsSection.tsx — UI для смены email
|
||||||
|
__tests__/ — тесты UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Не входит в scope
|
||||||
|
|
||||||
|
- Отправка email с кодом подтверждения (верификация через ссылку в ответе API)
|
||||||
|
- OAuth для админа (админ только email/код)
|
||||||
|
- Синтетический email для Яндекса (Яндекс всегда возвращает email)
|
||||||
Reference in New Issue
Block a user