diff --git a/README.md b/README.md
index 7bf429b..0e44a46 100644
--- a/README.md
+++ b/README.md
@@ -81,12 +81,13 @@ npm run dev
## Проверка функционала
-1. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
-2. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
-3. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
-4. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
-5. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`).
-6. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
+1. **Регистрация**: если хотите пройти сценарий «с нуля», откройте `/register`, заполните форму (имя, email, пароль, пожелания) и следуйте подсказкам. При отключённом подтверждении почты вы сразу попадёте на онбординг, при включённом — получите код на почту (в dev-режиме код отображается прямо в браузере).
+2. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
+3. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
+4. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
+5. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
+6. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). Для просмотра экрана кандидата используйте пункт «Просмотр от лица пилота» — он откроет `/` в режиме read-only и добавит кнопку «Вернуться к HR».
+7. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
### Подтверждение электронной почты
diff --git a/backend/alembic/versions/20240927_0004_user_profile_details.py b/backend/alembic/versions/20240927_0004_user_profile_details.py
new file mode 100644
index 0000000..8d9e704
--- /dev/null
+++ b/backend/alembic/versions/20240927_0004_user_profile_details.py
@@ -0,0 +1,22 @@
+"""Add profile preference fields"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '20240927_0004'
+down_revision = '20240927_0003'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column('users', sa.Column('preferred_branch', sa.String(length=160), nullable=True))
+ op.add_column('users', sa.Column('motivation', sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column('users', 'motivation')
+ op.drop_column('users', 'preferred_branch')
diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py
index f6f5023..512994c 100644
--- a/backend/app/api/routes/auth.py
+++ b/backend/app/api/routes/auth.py
@@ -9,11 +9,12 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.config import settings
-from app.core.security import create_access_token, verify_password
+from app.core.security import create_access_token, verify_password, get_password_hash
from app.db.session import get_db
-from app.models.user import User
+from app.models.rank import Rank
+from app.models.user import User, UserRole
from app.schemas.auth import EmailConfirm, EmailRequest, Token
-from app.schemas.user import UserLogin, UserRead
+from app.schemas.user import UserLogin, UserRead, UserRegister
from app.services.email_confirmation import confirm_email as mark_confirmed
from app.services.email_confirmation import issue_confirmation_token
@@ -24,20 +25,71 @@ router = APIRouter(prefix="/auth", tags=["auth"])
def login(user_in: UserLogin, db: Session = Depends(get_db)) -> Token:
"""Проверяем логин и выдаём JWT."""
+ # 1. Находим пользователя по e-mail. Для супер-новичка: `.first()` вернёт
+ # сам объект пользователя либо `None`, если почта не зарегистрирована.
user = db.query(User).filter(User.email == user_in.email).first()
+ # 2. Если пользователь не найден или пароль не совпал — сразу возвращаем 401.
if not user or not verify_password(user_in.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные данные")
if settings.require_email_confirmation and not user.is_email_confirmed:
+ # 3. Когда включено подтверждение почты, запрещаем вход до завершения процедуры.
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Подтвердите e-mail, прежде чем войти",
)
+ # 4. Генерируем короткоживущий JWT. Он будет храниться в httpOnly-cookie на фронте.
token = create_access_token(user.email, timedelta(minutes=settings.access_token_expire_minutes))
return Token(access_token=token)
+@router.post(
+ "/register",
+ response_model=Token | dict[str, str | None],
+ status_code=status.HTTP_201_CREATED,
+ summary="Регистрация нового пилота",
+)
+def register(user_in: UserRegister, db: Session = Depends(get_db)) -> Token | dict[str, str | None]:
+ """Создаём учётную запись пилота и при необходимости отправляем код подтверждения."""
+
+ # 1. Проверяем, не зарегистрирован ли пользователь раньше: уникальный e-mail — обязательное условие.
+ existing = db.query(User).filter(User.email == user_in.email).first()
+ if existing:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Пользователь с таким email уже существует")
+
+ # 2. Назначаем новичку самый базовый ранг. Если ранги ещё не заведены в БД,
+ # `base_rank` будет `None`, поэтому ниже используем условный оператор.
+ base_rank = db.query(Rank).order_by(Rank.required_xp).first()
+ user = User(
+ email=user_in.email,
+ full_name=user_in.full_name,
+ hashed_password=get_password_hash(user_in.password),
+ role=UserRole.PILOT,
+ preferred_branch=user_in.preferred_branch,
+ motivation=user_in.motivation,
+ current_rank_id=base_rank.id if base_rank else None,
+ is_email_confirmed=not settings.require_email_confirmation,
+ )
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+
+ if settings.require_email_confirmation:
+ # 3. При включённом подтверждении выдаём одноразовый код и подсказываем,
+ # что делать дальше. В dev-режиме отдаём код прямо в ответе, чтобы QA
+ # мог пройти сценарий без почтового сервиса.
+ token = issue_confirmation_token(user, db)
+ return {
+ "detail": "Мы отправили письмо с подтверждением. Введите код, чтобы активировать аккаунт.",
+ "debug_token": token if settings.environment != 'production' else None,
+ }
+
+ # 4. Если подтверждение выключено, сразу создаём JWT и возвращаем его фронтенду.
+ access_token = create_access_token(user.email, timedelta(minutes=settings.access_token_expire_minutes))
+ return Token(access_token=access_token)
+
+
@router.get("/me", response_model=UserRead, summary="Текущий пользователь")
def read_current_user(current_user: User = Depends(get_current_user)) -> UserRead:
"""Простая проверка токена."""
@@ -49,10 +101,12 @@ def read_current_user(current_user: User = Depends(get_current_user)) -> UserRea
def request_confirmation(payload: EmailRequest, db: Session = Depends(get_db)) -> dict[str, str | None]:
"""Генерируем код подтверждения и в режиме разработки возвращаем его в ответе."""
+ # 1. Находим пользователя и убеждаемся, что он вообще существует.
user = db.query(User).filter(User.email == payload.email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
+ # 2. Генерируем новый код подтверждения, предыдущий становится недействительным.
token = issue_confirmation_token(user, db)
hint = None
@@ -69,15 +123,19 @@ def request_confirmation(payload: EmailRequest, db: Session = Depends(get_db)) -
def confirm_email(payload: EmailConfirm, db: Session = Depends(get_db)) -> dict[str, str]:
"""Активируем почту, если код совпал."""
+ # 1. Повторяем поиск пользователя: это второй шаг флоу подтверждения.
user = db.query(User).filter(User.email == payload.email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
if not user.email_confirmation_token:
+ # 2. Если токена нет, значит пользователь не запрашивал письмо.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Код не запрошен")
if user.email_confirmation_token != payload.token:
+ # 3. Защищаемся от неправильного кода.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
+ # 4. Отмечаем почту подтверждённой и убираем токен.
mark_confirmed(user, db)
return {"detail": "E-mail подтверждён"}
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index dc04439..42ab26a 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -6,7 +6,7 @@ from datetime import datetime
from enum import Enum
from typing import List, Optional
-from sqlalchemy import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
@@ -39,6 +39,10 @@ class User(Base, TimestampMixin):
is_email_confirmed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
email_confirmation_token: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
email_confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
+ # Храним пожелания кандидата: в какой ветке развития он хочет расти.
+ preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
+ # Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
+ motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
current_rank = relationship("Rank", back_populates="pilots")
competencies: Mapped[List["UserCompetency"]] = relationship(
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 37d2c71..44dc306 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -85,3 +85,14 @@ class UserLogin(BaseModel):
email: EmailStr
password: str
+
+
+class UserRegister(BaseModel):
+ """Регистрация нового пилота."""
+
+ email: EmailStr
+ full_name: str
+ password: str
+ # Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
+ preferred_branch: Optional[str] = None
+ motivation: Optional[str] = None
diff --git a/backend/app/services/email_confirmation.py b/backend/app/services/email_confirmation.py
index cacdad1..67c36cd 100644
--- a/backend/app/services/email_confirmation.py
+++ b/backend/app/services/email_confirmation.py
@@ -13,7 +13,11 @@ from app.models.user import User
def issue_confirmation_token(user: User, db: Session) -> str:
"""Генерируем и сохраняем одноразовый код подтверждения."""
+ # В `token_urlsafe(16)` мы просим secrets создать криптостойкую строку длиной ~22 символа,
+ # которую удобно вводить вручную. Каждый новый вызов полностью обнуляет предыдущий код.
token = token_urlsafe(16)
+ # Сбрасываем флаги подтверждения — это важно, если пользователь запрашивает
+ # повторное письмо: только введя актуальный код, он снова активируется.
user.email_confirmation_token = token
user.is_email_confirmed = False
user.email_confirmed_at = None
@@ -26,8 +30,11 @@ def issue_confirmation_token(user: User, db: Session) -> str:
def confirm_email(user: User, db: Session) -> None:
"""Помечаем почту подтверждённой."""
+ # Флаг `is_email_confirmed` избавляет нас от лишних запросов при каждой авторизации.
user.is_email_confirmed = True
+ # Токен больше не нужен — удаляем его, чтобы предотвратить повторное использование.
user.email_confirmation_token = None
+ # Сохраняем точное время подтверждения: пригодится в аналитике и для аудита.
user.email_confirmed_at = datetime.now(timezone.utc)
db.add(user)
db.commit()
diff --git a/frontend/src/app/admin/exit-view/route.ts b/frontend/src/app/admin/exit-view/route.ts
new file mode 100644
index 0000000..8bdc28f
--- /dev/null
+++ b/frontend/src/app/admin/exit-view/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from 'next/server';
+import { disablePilotView, requireRole } from '../../../lib/auth/session';
+
+export async function GET(request: Request) {
+ // Возвращаем HR к своему интерфейсу.
+ // Cookie `alabuga_view_as` хранит флаг режима просмотра, удаляем его и редиректим в админку.
+ await requireRole('hr');
+ disablePilotView();
+ return NextResponse.redirect(new URL('/admin', request.url));
+}
diff --git a/frontend/src/app/admin/view-as/route.ts b/frontend/src/app/admin/view-as/route.ts
new file mode 100644
index 0000000..99c05c0
--- /dev/null
+++ b/frontend/src/app/admin/view-as/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from 'next/server';
+import { enablePilotView, requireRole } from '../../../lib/auth/session';
+
+export async function GET(request: Request) {
+ // Доступно только HR: включаем режим просмотра и отправляем на дашборд кандидата.
+ // Благодаря этому HR увидит интерфейс пилота без необходимости заводить отдельную учётку.
+ await requireRole('hr');
+ enablePilotView();
+ return NextResponse.redirect(new URL('/', request.url));
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index df9e1ed..2926876 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -12,17 +12,33 @@ export default async function RootLayout({ children }: { children: React.ReactNo
// Пробуем получить сессию (если пользователь не авторизован, вернётся null).
const session = await getSession();
- // Формируем список пунктов меню в зависимости от роли пользователя.
- const links = session
- ? [
- { href: '/', label: 'Дашборд' },
- { href: '/onboarding', label: 'Онбординг' },
- { href: '/missions', label: 'Миссии' },
- { href: '/journal', label: 'Журнал' },
- { href: '/store', label: 'Магазин' },
- ...(session.role === 'hr' ? [{ href: '/admin', label: 'HR панель' }] : []),
- ]
- : [{ href: '/login', label: 'Войти' }];
+ // Сохраняем подсказки, кто сейчас вошёл и включил ли HR режим «просмотра глазами пилота».
+ const isHr = session?.role === 'hr';
+ const viewingAsPilot = Boolean(session?.viewAsPilot);
+
+ // Формируем пункты меню в зависимости от текущего режима.
+ let links: Array<{ href: string; label: string }> = [];
+
+ if (!session) {
+ links = [{ href: '/login', label: 'Войти' }];
+ } else if (isHr && !viewingAsPilot) {
+ links = [
+ { href: '/admin', label: 'HR панель' },
+ { href: '/admin/view-as', label: 'Просмотр от лица пилота' },
+ ];
+ } else {
+ links = [
+ { href: '/', label: 'Дашборд' },
+ { href: '/onboarding', label: 'Онбординг' },
+ { href: '/missions', label: 'Миссии' },
+ { href: '/journal', label: 'Журнал' },
+ { href: '/store', label: 'Магазин' },
+ ];
+ if (isHr) {
+ // Дополнительный пункт для HR: быстрый выход из режима просмотра.
+ links.push({ href: '/admin/exit-view', label: 'Вернуться к HR' });
+ }
+ }
return (
@@ -52,6 +68,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{link.label}
))}
+ {viewingAsPilot && (
+
+ режим просмотра пилота
+
+ )}
{session && (
<>
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx
index 2cdac20..0d1cb38 100644
--- a/frontend/src/app/login/page.tsx
+++ b/frontend/src/app/login/page.tsx
@@ -43,7 +43,11 @@ async function authenticate(formData: FormData) {
}
}
-export default async function LoginPage({ searchParams }: { searchParams: { error?: string } }) {
+export default async function LoginPage({
+ searchParams
+}: {
+ searchParams: { error?: string; info?: string };
+}) {
// Если пользователь уже вошёл, сразу перенаправляем его на рабочую страницу.
const existing = await getSession();
if (existing) {
@@ -51,6 +55,7 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
}
const message = searchParams.error;
+ const info = searchParams.info;
return (
{info}
} {message &&{message}
} ++ Впервые на платформе? Зарегистрируйтесь и начните путь пилота. +
); diff --git a/frontend/src/app/login/styles.module.css b/frontend/src/app/login/styles.module.css index f8c69ec..23eae1a 100644 --- a/frontend/src/app/login/styles.module.css +++ b/frontend/src/app/login/styles.module.css @@ -37,6 +37,16 @@ text-align: center; } +.info { + margin: 0; + padding: 0.75rem 1rem; + border-radius: 12px; + background: rgba(0, 184, 148, 0.12); + border: 1px solid rgba(0, 184, 148, 0.35); + color: var(--success); + text-align: center; +} + .field { display: grid; gap: 0.5rem; @@ -65,3 +75,14 @@ .submit:hover { opacity: 0.9; } + +.footer { + margin: 0; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; +} + +.footer a { + color: var(--accent-light); +} diff --git a/frontend/src/app/logout/route.ts b/frontend/src/app/logout/route.ts index 5fd8338..f7a4f4f 100644 --- a/frontend/src/app/logout/route.ts +++ b/frontend/src/app/logout/route.ts @@ -2,8 +2,11 @@ import { NextResponse } from 'next/server'; export function GET(request: Request) { // Очищаем cookie и мгновенно перенаправляем пользователя на страницу входа. + // Здесь не используем `destroySession`, потому что `NextResponse` позволяет + // выставить заголовки прямо в объекте ответа. const target = new URL('/login', request.url); const response = NextResponse.redirect(target); response.cookies.delete('alabuga_session'); + response.cookies.delete('alabuga_view_as'); return response; } diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx new file mode 100644 index 0000000..09f7e21 --- /dev/null +++ b/frontend/src/app/register/page.tsx @@ -0,0 +1,105 @@ +import { redirect } from 'next/navigation'; +import { apiFetch } from '../../lib/api'; +import { createSession, getSession } from '../../lib/auth/session'; + +import styles from './styles.module.css'; + +// Server Action: оформляет регистрацию и в зависимости от настроек либо сразу +// авторизует пользователя, либо перенаправляет его на страницу входа +// с подсказкой подтвердить почту. +async function registerAction(formData: FormData) { + 'use server'; + + // 1. Извлекаем значения из формы. Метод `FormData.get` возвращает `FormDataEntryValue` | null, + // поэтому приводим к строке и удаляем пробелы по краям. + const fullName = String(formData.get('fullName') ?? '').trim(); + const email = String(formData.get('email') ?? '').trim(); + const password = String(formData.get('password') ?? '').trim(); + // Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки. + const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined; + const motivation = String(formData.get('motivation') ?? '').trim() || undefined; + + if (!fullName || !email || !password) { + redirect('/register?error=' + encodeURIComponent('Заполните имя, email и пароль.')); + } + + try { + // 2. Собираем payload в формате, который ожидает FastAPI. + const payload = { full_name: fullName, email, password, preferred_branch: preferredBranch, motivation }; + const response = await apiFetch