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 (
@@ -62,6 +67,7 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro hr@alabuga.space / orbita123.

+ {info &&

{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('/auth/register', { + method: 'POST', + body: JSON.stringify(payload) + }); + + if (response && 'access_token' in response) { + // 3a. Если подтверждение почты отключено — получаем JWT, создаём сессию и отправляем пилота на онбординг. + createSession({ token: response.access_token, role: 'pilot', fullName }); + redirect('/onboarding'); + } + + // 3b. При включённом подтверждении backend возвращает текст подсказки и debug-код. + const detail = response?.detail ?? 'Проверьте почту для подтверждения.'; + const debug = response?.debug_token ? ` Код: ${response.debug_token}` : ''; + redirect('/login?info=' + encodeURIComponent(`${detail}${debug}`)); + } catch (error) { + console.error('Registration failed:', error); + // 4. Любые сетевые/серверные ошибки показываем пользователю через query string. + const message = error instanceof Error ? error.message : 'Не удалось завершить регистрацию.'; + redirect('/register?error=' + encodeURIComponent(message)); + } +} + +export default async function RegisterPage({ searchParams }: { searchParams: { error?: string } }) { + const existing = await getSession(); + if (existing) { + redirect(existing.role === 'hr' ? '/admin' : '/'); + } + + const errorMessage = searchParams.error; + + return ( +
+
+

Регистрация пилота

+

+ После регистрации вы попадёте на онбординг и сможете выполнять миссии. + Если включено подтверждение почты, мы отправим код на указанную почту. +

+ + {errorMessage &&

{errorMessage}

} + + + + + +