feat: load Outfit font from static files
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,300 +1,225 @@
|
||||
# Auth Redesign — Spec
|
||||
# Auth Page Redesign — Spec
|
||||
|
||||
**Date:** 2026-05-22
|
||||
**Goal:** Переработать систему аутентификации: OAuth запрашивает только email, убрать внешние аватары, добавить вход по email+паролю, дать пользователям связывать методы входа в ЛК.
|
||||
**Goal:** Минималистичный редизайн страницы входа с лёгким брендингом (медведь + слоган), pill-кнопками и Paper-карточкой.
|
||||
|
||||
**Style:** Минималистичный, чистый. Одна колонка по центру.
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model (Prisma)
|
||||
## 1. Шрифт Outfit
|
||||
|
||||
### 1.1. Модель `User` — изменения
|
||||
**Проблема:** Outfit указан в MUI-теме, но не загружается. Фактически везде системный Segoe UI.
|
||||
|
||||
| Поле | Было | Стало |
|
||||
|------|------|-------|
|
||||
| `passwordHash` | `String?` (не использовалось) | Задействуем. Хранит bcrypt-хеш. `null` если пароль не установлен. |
|
||||
| `avatarType` | `String?` (`'oauth'` / `'generated'`) | **Удалить.** Все аватары внутренние (DiceBear). |
|
||||
| `avatar` | `String?` (URL или data:uri) | Только DiceBear URL или `null` (генерируется на лету) |
|
||||
| `avatarStyle` | `String?` | Без изменений. |
|
||||
**Исправление:** Скачать шрифт Outfit (woff2, веса 400/500/600/700) и разместить в `client/public/fonts/`. Добавить `@font-face` в `client/src/app/styles/global.css`:
|
||||
|
||||
Остальные поля (`id`, `email`, `displayName`, `firstName`, `lastName`, `gender`, `createdAt`, `updatedAt`) — без изменений. `firstName`, `lastName`, `gender` больше не заполняются при OAuth, но остаются в БД (могут быть заполнены пользователем вручную позже).
|
||||
|
||||
### 1.2. Модель `OAuthAccount` — без изменений
|
||||
|
||||
```prisma
|
||||
model OAuthAccount {
|
||||
id String @id @default(cuid())
|
||||
provider String // 'vk' | 'yandex'
|
||||
providerUserId String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String?
|
||||
refreshToken String? // зарезервировано, не используется сейчас
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([provider, providerUserId])
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('/fonts/Outfit-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('/fonts/Outfit-Medium.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('/fonts/Outfit-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('/fonts/Outfit-Bold.woff2') format('woff2');
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3. Модель `AuthCode` — без изменений
|
||||
|
||||
### 1.4. Миграции
|
||||
|
||||
1. Удаление колонки `avatarType` из `User`
|
||||
2. Миграция данных: для всех пользователей с `avatarType = 'oauth'` установить `avatar = null` (внешние URL больше не используются, аватар перегенерируется DiceBear)
|
||||
Файлы woff2 скачать с Google Fonts или из CDN и положить в `client/public/fonts/`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Авторизация по email+паролю
|
||||
## 2. Фон страницы
|
||||
|
||||
### 2.1. Регистрация
|
||||
|
||||
`POST /api/auth/register` (новый, без аутентификации)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "Abcdef1!",
|
||||
"displayName": "Иван" // optional
|
||||
}
|
||||
```
|
||||
|
||||
**Валидация:**
|
||||
- `email`: валидный email, нормализация (trim + lowercase), уникальность
|
||||
- `password`: минимум 8 символов, минимум 1 буква, 1 цифра, 1 спецсимвол
|
||||
- `displayName`: опционально, строка до 100 символов. Если не передан — берётся часть email до `@`
|
||||
|
||||
**Логика:**
|
||||
1. Проверка, что email не занят → 409 если занят
|
||||
2. `passwordHash = await bcrypt.hash(password, 10)`
|
||||
3. Создание пользователя: `email`, `passwordHash`, `displayName`, `avatar = null`, `avatarStyle = 'avataaars'`
|
||||
4. Создание `NotificationPreference` (как сейчас в verify-code)
|
||||
5. Возврат JWT + user
|
||||
|
||||
**Response 201:**
|
||||
```json
|
||||
{
|
||||
"token": "jwt...",
|
||||
"user": { "id", "email", "displayName", "avatar", "avatarStyle", "isAdmin": false }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2. Вход
|
||||
|
||||
`POST /api/auth/login` (новый, без аутентификации)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "Abcdef1!"
|
||||
}
|
||||
```
|
||||
|
||||
**Rate limit:** максимум 5 попыток в минуту с одного IP (использовать `@fastify/rate-limit`). При превышении — `429 Too Many Requests`.
|
||||
|
||||
**Логика:**
|
||||
1. Нормализация email
|
||||
2. Поиск пользователя по email
|
||||
3. Если пользователь не найден ИЛИ `passwordHash === null` → `401 Invalid email or password` (одинаковый ответ для безопасности)
|
||||
4. `await bcrypt.compare(password, user.passwordHash)` → если не совпадает → `401`
|
||||
5. Возврат JWT + user
|
||||
|
||||
### 2.3. Админ и пароль
|
||||
|
||||
- Админ (`email === ADMIN_EMAIL`) **не может** зарегистрироваться или войти по паролю
|
||||
- `POST /api/auth/register` и `POST /api/auth/login` возвращают `403` для админского email
|
||||
- Админ также **не может** установить пароль через `POST /api/me/password`
|
||||
- `background.default` + лёгкий радиальный градиент
|
||||
- Градиент: от центра к краям, `primary.main` с 3-5% opacity
|
||||
- Реализация: `sx` prop на корневом `<Box>`: `background: radial-gradient(circle at 50% 30%, ${alpha(theme.palette.primary.main, 0.05)} 0%, transparent 70%)`
|
||||
|
||||
---
|
||||
|
||||
## 3. OAuth (только email)
|
||||
|
||||
### 3.1. Scope
|
||||
|
||||
| Провайдер | Было | Стало |
|
||||
|-----------|------|-------|
|
||||
| VK | `email` | `email` (без изменений, но больше не запрашиваем профиль) |
|
||||
| Яндекс | `login:email login:info` | `login:email` |
|
||||
|
||||
### 3.2. Callback — что убираем
|
||||
|
||||
**VK:**
|
||||
- Больше не делаем `users.get` после получения токена
|
||||
- Не сохраняем: `first_name`, `last_name`, `photo_200`, `sex`
|
||||
|
||||
**Яндекс:**
|
||||
- Всё ещё вызываем `GET https://login.yandex.ru/info` (нужен для получения email)
|
||||
- Из ответа берём только `default_email` или первый из `emails`
|
||||
- **Не сохраняем:** `first_name`, `last_name`, `display_name`, `sex`, `default_avatar_id`
|
||||
|
||||
### 3.3. Callback — новая логика
|
||||
## 3. Компоновка
|
||||
|
||||
```
|
||||
1. Обмен code на access_token (как сейчас)
|
||||
2. Извлечение email из ответа провайдера:
|
||||
- VK: поле `email` в ответе access_token
|
||||
- Яндекс: вызываем `/info`, из ответа берём `default_email` или первый из `emails`
|
||||
3. Если email отсутствует → редирект с ?oauthError=no_email
|
||||
4. Нормализация email
|
||||
5. Поиск пользователя по email:
|
||||
a) Найден → привязываем OAuthAccount (если ещё не привязан), возвращаем JWT
|
||||
b) Не найден → создаём нового:
|
||||
- email
|
||||
- displayName = часть email до @
|
||||
- avatar = null
|
||||
- avatarStyle = 'avataaars'
|
||||
- Создаём OAuthAccount
|
||||
- Создаём NotificationPreference
|
||||
- Возвращаем JWT
|
||||
6. Редирект на CLIENT_PUBLIC_URL/auth/callback?token=...
|
||||
┌──────────────────────────────────────────┐
|
||||
│ (воздух) │
|
||||
│ 🐻 BearLogo 72px │
|
||||
│ Добро пожаловать в Любимый Креатив │
|
||||
│ (subtitle, text.secondary) │
|
||||
│ (воздух) │
|
||||
│ ┌─────── Paper 440px max-width ──────┐ │
|
||||
│ │ [Пароль] [Код] [Другой способ] │ │
|
||||
│ │ │ │
|
||||
│ │ Вход / Регистрация │ │
|
||||
│ │ │ │
|
||||
│ │ Email: __________________ │ │
|
||||
│ │ Пароль: __________________ │ │
|
||||
│ │ │ │
|
||||
│ │ [────────── Войти ──────────] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ (воздух) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Fallback-email `{provider}_{id}@oauth.craftshop.local` — убираем.** Если провайдер не дал email — ошибка.
|
||||
|
||||
### 3.4. State-параметр
|
||||
|
||||
Без изменений. JWT с `expiresIn: 15m` для CSRF-защиты.
|
||||
Детали:
|
||||
- Корневой `<Box>`: `display: flex, alignItems: center, justifyContent: center, minHeight: calc(100vh - header)`
|
||||
- BearLogo: `<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}><BearLogo sx={{ fontSize: 72 }} /></Box>`
|
||||
- Заголовок: `variant="h5"`, `fontWeight: 700`, `textAlign: center`
|
||||
- Слоган: `variant="body2"`, `color: text.secondary`, `textAlign: center`, `mb: 3`
|
||||
- Paper: `maxWidth: 440`, `mx: auto`, `p: 4`, `borderRadius: 3` (12px), `border: 1px solid divider`, мягкая тень
|
||||
|
||||
---
|
||||
|
||||
## 4. Связывание аккаунтов
|
||||
## 4. Pill-переключатель методов
|
||||
|
||||
### 4.1. Авто-связывание
|
||||
Вместо MUI Tabs — три MUI Button в ряд:
|
||||
|
||||
При OAuth-входе: если email из OAuth совпадает с email существующего пользователя — автоматически создаётся `OAuthAccount`, связывающий провайдера с пользователем. Вход происходит мгновенно.
|
||||
|
||||
### 4.2. Ручное связывание — страница настроек `/me`
|
||||
|
||||
**Получение статуса методов:** `GET /api/me/auth-methods` (новый, требует `authenticate`)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"methods": [
|
||||
{ "type": "password", "active": true },
|
||||
{ "type": "vk", "active": false },
|
||||
{ "type": "yandex", "active": true }
|
||||
]
|
||||
}
|
||||
```tsx
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 3 }}>
|
||||
<Button
|
||||
variant={tab === 0 ? 'contained' : 'outlined'}
|
||||
sx={{ borderRadius: '24px', flex: 1, textTransform: 'none' }}
|
||||
onClick={() => setTab(0)}
|
||||
>
|
||||
Пароль
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 1 ? 'contained' : 'outlined'}
|
||||
sx={{ borderRadius: '24px', flex: 1, textTransform: 'none' }}
|
||||
onClick={() => setTab(1)}
|
||||
>
|
||||
Код
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 2 ? 'contained' : 'outlined'}
|
||||
sx={{ borderRadius: '24px', flex: 1, textTransform: 'none' }}
|
||||
onClick={() => setTab(2)}
|
||||
>
|
||||
Другой способ
|
||||
</Button>
|
||||
</Stack>
|
||||
```
|
||||
|
||||
Логика:
|
||||
- `type: "password"` — `active: user.passwordHash !== null`
|
||||
- `type: "vk"` / `type: "yandex"` — `active: exists(OAuthAccount)`
|
||||
---
|
||||
|
||||
### 4.3. Привязка OAuth
|
||||
## 5. Под-переключатель Вход/Регистрация
|
||||
|
||||
`GET /api/auth/oauth/{provider}/link` (новый, требует `authenticate`)
|
||||
Только на вкладке «Пароль»:
|
||||
|
||||
1. Генерирует state-JWT с `{ userId, provider, action: 'link' }`, `expiresIn: 15m`
|
||||
2. Редиректит на страницу авторизации провайдера
|
||||
3. После callback проверяет `action: 'link'` в state
|
||||
4. Создаёт `OAuthAccount` для указанного `userId` (нормальный upsert)
|
||||
5. Редиректит на `/me?linked={provider}`
|
||||
|
||||
### 4.4. Привязка пароля
|
||||
|
||||
`POST /api/me/password` (новый, требует `authenticate`)
|
||||
|
||||
**Request:** `{ "password": "Abcdef1!" }`
|
||||
|
||||
**Логика:**
|
||||
1. Если `user.email === ADMIN_EMAIL` → `403`
|
||||
2. Если `user.passwordHash !== null` → `409 Password already set` (для смены использовать отдельный метод)
|
||||
3. Валидация пароля (8+, буква+цифра+спецсимвол)
|
||||
4. `user.passwordHash = await bcrypt.hash(password, 10)`
|
||||
5. Сохранение пользователя
|
||||
|
||||
### 4.5. Отвязывание
|
||||
|
||||
`DELETE /api/me/oauth/{provider}` (новый, требует `authenticate`)
|
||||
|
||||
**Логика:**
|
||||
1. Удаление `OAuthAccount` по `userId + provider`
|
||||
2. Если `OAuthAccount` не найден → `404`
|
||||
3. **Проверка последнего метода:** после удаления, если у пользователя нет ни `passwordHash`, ни других `OAuthAccount` → `400 Cannot remove last auth method`
|
||||
4. Возврат `200`
|
||||
|
||||
**Примечание:** отвязывание пароля (установка `passwordHash = null`) пока не делаем — можно добавить позже.
|
||||
|
||||
### 4.6. Админ и связывание
|
||||
|
||||
- `POST /api/me/password` → `403` для админа
|
||||
- OAuth-привязка через `/link` → `403` для админа
|
||||
- OAuth-отвязывание → `403` для админа
|
||||
```tsx
|
||||
<Stack direction="row" justifyContent="center" spacing={3} sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{
|
||||
color: !isRegister ? 'primary.main' : 'text.secondary',
|
||||
borderBottom: !isRegister ? 2 : 0,
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 0,
|
||||
pb: 0.5,
|
||||
}}
|
||||
onClick={() => setIsRegister(false)}
|
||||
>
|
||||
Вход
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{
|
||||
color: isRegister ? 'primary.main' : 'text.secondary',
|
||||
borderBottom: isRegister ? 2 : 0,
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 0,
|
||||
pb: 0.5,
|
||||
}}
|
||||
onClick={() => setIsRegister(true)}
|
||||
>
|
||||
Регистрация
|
||||
</Button>
|
||||
</Stack>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Email-код (без изменений логики)
|
||||
## 6. Формы по вкладкам
|
||||
|
||||
`POST /api/auth/request-code` и `POST /api/auth/verify-code` работают как раньше, изменений нет. Админ входит только этим способом.
|
||||
### Пароль (вход)
|
||||
- Email TextField
|
||||
- Пароль TextField
|
||||
- Button contained fullWidth: «Войти»
|
||||
|
||||
### Пароль (регистрация)
|
||||
- Email TextField
|
||||
- Имя TextField (опционально, helperText: «Необязательно. Будет использована часть email»)
|
||||
- Пароль TextField
|
||||
- Подтверждение пароля TextField (с валидацией совпадения)
|
||||
- Button contained fullWidth: «Зарегистрироваться»
|
||||
|
||||
### Код
|
||||
- Строка: Email + кнопка «Отправить код»
|
||||
- Строка: поле Код + кнопка «Войти»
|
||||
- Alert outlined success после успешной отправки
|
||||
|
||||
### Другой способ
|
||||
- OAuthButtons — стилизовать кнопки как outlined pill (borderRadius 24px, fullWidth)
|
||||
- Кнопки: «Войти через Яндекс ID», «Войти через VK ID»
|
||||
|
||||
---
|
||||
|
||||
## 6. Изменения на клиенте
|
||||
## 7. Alert'ы
|
||||
|
||||
### 6.1. Страница `/auth`
|
||||
|
||||
**3 вкладки:**
|
||||
- **«Пароль»** — переключатель Вход/Регистрация. Вход: email + пароль. Регистрация: email + пароль + подтверждение пароля + имя (опционально).
|
||||
- **«Код»** — как сейчас: email → отправить код → ввести код.
|
||||
- **«Другой способ»** — кнопки Войти через VK / Яндекс.
|
||||
|
||||
### 6.2. Страница `/me` (настройки)
|
||||
|
||||
Новая секция «Методы входа»:
|
||||
- Список методов с индикаторами «привязан» / «не привязан»
|
||||
- Кнопки «Привязать» (редирект на OAuth или форма пароля)
|
||||
- Кнопки «Отвязать» (disabled если это последний метод)
|
||||
- Для админа — секция скрыта
|
||||
|
||||
### 6.3. Effector-стейт
|
||||
|
||||
- `$token`, `$user`, `tokenSet`, `logout` — без изменений
|
||||
- Добавить эффекты: `loginFx`, `registerFx`, `linkOAuthFx`, `setPasswordFx`, `unlinkOAuthFx`
|
||||
|
||||
### 6.4. Компоненты
|
||||
|
||||
- `UserAvatar` — убрать проверку `avatarType`, всегда использовать DiceBear (сохранённый `avatar` или генерация на лету)
|
||||
- `OAuthButtons` — без изменений (URL те же)
|
||||
- Все ошибки: `Alert severity="error" variant="outlined"` внутри Paper, над формой
|
||||
- Успешная отправка кода: `Alert severity="success" variant="outlined"`
|
||||
- OAuth-ошибки: так же внутри Paper
|
||||
|
||||
---
|
||||
|
||||
## 7. Тестирование
|
||||
## 8. Иконки в TextField
|
||||
|
||||
### 7.1. Серверные тесты
|
||||
Добавить `InputAdornment` с иконками для визуального улучшения:
|
||||
- Email: `<Mail>` иконка (lucide-react)
|
||||
- Пароль: `<Lock>` иконка
|
||||
|
||||
- `POST /api/auth/register` — успешная регистрация, дубликат email, слабый пароль
|
||||
- `POST /api/auth/login` — успешный вход, неверный пароль, несуществующий email, превышение rate limit
|
||||
- OAuth callback — создание нового пользователя с email, авто-связывание по email, ошибка при отсутствии email от провайдера
|
||||
- `POST /api/me/password` — установка, повторная установка (409), админ (403)
|
||||
- `GET /api/me/auth-methods` — корректный список методов
|
||||
- `DELETE /api/me/oauth/{provider}` — отвязывание, последний метод (400), админ (403)
|
||||
- Админ не может войти через `/login` (403)
|
||||
|
||||
### 7.2. Клиентские тесты
|
||||
|
||||
- Страница `/auth` — наличие трёх вкладок, переключение
|
||||
- Форма регистрации — валидация пароля, подтверждение
|
||||
- Форма входа — обработка ошибок
|
||||
- `/me` — отображение методов, кнопки привязки/отвязки
|
||||
Иконки только если это не перегружает минималистичный стиль. Решение — использовать в полях email и пароля `startAdornment`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Миграция существующих пользователей
|
||||
## 9. Адаптивность
|
||||
|
||||
1. Все пользователи с `avatarType = 'oauth'`: `avatar = null`, `avatarType = null`. Аватар перегенерируется DiceBear при следующем отображении.
|
||||
2. `avatarType` колонка удаляется из БД.
|
||||
3. Существующие OAuth-аккаунты работают как раньше, но при следующем входе через OAuth обновляется логика (не запрашиваем профиль).
|
||||
4. `firstName`, `lastName`, `gender` у существующих OAuth-пользователей остаются в БД (не удаляем, просто больше не пополняем из OAuth).
|
||||
- `min-height` вместо `height` (использовать `minHeight: calc(100vh - 64px)`)
|
||||
- Paper: `mx: 2` на мобильных, `mx: auto` на десктопе
|
||||
- Pill-кнопки остаются в ряд на всех разрешениях (они и так компактные)
|
||||
- Отправка кода: на мобильных поля в столбец (уже есть `direction={{ xs: 'column', sm: 'row' }}`)
|
||||
|
||||
---
|
||||
|
||||
## 9. Заметки
|
||||
## 10. Плавные переходы
|
||||
|
||||
- **bcrypt** — не установлен, нужно добавить `npm install bcrypt` в server.
|
||||
- **Rate limit** — `@fastify/rate-limit` не установлен. Добавить или реализовать самодельный in-memory rate limiter (5 попыток/мин/IP).
|
||||
- Все новые эндпоинты должны валидироваться через JSON Schema (как существующие).
|
||||
- Пароль никогда не возвращается в ответах API и не логируется.
|
||||
- Существующий `User.passwordHash` (null у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать.
|
||||
- Смена вкладок: контент формы — `opacity` transition 200ms
|
||||
- Смена Вход/Регистрация: поля появляются с fade-in
|
||||
|
||||
---
|
||||
|
||||
## 11. Заметки
|
||||
|
||||
- BearLogo уже существует (`@/shared/ui/BearLogo`)
|
||||
- OAuthButtons существует (`@/features/auth-oauth`)
|
||||
- Менять бизнес-логику (хуки, mutations) не нужно — только вёрстку
|
||||
- Текущий AuthPage — 232 строки, нужно заменить полностью
|
||||
- Все цвета брать из темы (`primary.main`, `text.secondary`, `divider`, `background.paper`)
|
||||
- Для градиента использовать `useTheme` + `alpha` из MUI
|
||||
|
||||
Reference in New Issue
Block a user