Adding registration doesnt working
This commit is contained in:
parent
418c8bdb65
commit
1535453cb7
13
README.md
13
README.md
|
|
@ -81,12 +81,13 @@ npm run dev
|
||||||
|
|
||||||
## Проверка функционала
|
## Проверка функционала
|
||||||
|
|
||||||
1. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
|
1. **Регистрация**: если хотите пройти сценарий «с нуля», откройте `/register`, заполните форму (имя, email, пароль, пожелания) и следуйте подсказкам. При отключённом подтверждении почты вы сразу попадёте на онбординг, при включённом — получите код на почту (в dev-режиме код отображается прямо в браузере).
|
||||||
2. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
|
2. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
|
||||||
3. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
|
3. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
|
||||||
4. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
4. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
|
||||||
5. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`).
|
5. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
||||||
6. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
|
6. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). Для просмотра экрана кандидата используйте пункт «Просмотр от лица пилота» — он откроет `/` в режиме read-only и добавит кнопку «Вернуться к HR».
|
||||||
|
7. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
|
||||||
|
|
||||||
### Подтверждение электронной почты
|
### Подтверждение электронной почты
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -9,11 +9,12 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
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.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.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 confirm_email as mark_confirmed
|
||||||
from app.services.email_confirmation import issue_confirmation_token
|
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:
|
def login(user_in: UserLogin, db: Session = Depends(get_db)) -> Token:
|
||||||
"""Проверяем логин и выдаём JWT."""
|
"""Проверяем логин и выдаём JWT."""
|
||||||
|
|
||||||
|
# 1. Находим пользователя по e-mail. Для супер-новичка: `.first()` вернёт
|
||||||
|
# сам объект пользователя либо `None`, если почта не зарегистрирована.
|
||||||
user = db.query(User).filter(User.email == user_in.email).first()
|
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):
|
if not user or not verify_password(user_in.password, user.hashed_password):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные данные")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные данные")
|
||||||
|
|
||||||
if settings.require_email_confirmation and not user.is_email_confirmed:
|
if settings.require_email_confirmation and not user.is_email_confirmed:
|
||||||
|
# 3. Когда включено подтверждение почты, запрещаем вход до завершения процедуры.
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Подтвердите e-mail, прежде чем войти",
|
detail="Подтвердите e-mail, прежде чем войти",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 4. Генерируем короткоживущий JWT. Он будет храниться в httpOnly-cookie на фронте.
|
||||||
token = create_access_token(user.email, timedelta(minutes=settings.access_token_expire_minutes))
|
token = create_access_token(user.email, timedelta(minutes=settings.access_token_expire_minutes))
|
||||||
return Token(access_token=token)
|
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="Текущий пользователь")
|
@router.get("/me", response_model=UserRead, summary="Текущий пользователь")
|
||||||
def read_current_user(current_user: User = Depends(get_current_user)) -> UserRead:
|
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]:
|
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()
|
user = db.query(User).filter(User.email == payload.email).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
|
||||||
|
|
||||||
|
# 2. Генерируем новый код подтверждения, предыдущий становится недействительным.
|
||||||
token = issue_confirmation_token(user, db)
|
token = issue_confirmation_token(user, db)
|
||||||
|
|
||||||
hint = None
|
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]:
|
def confirm_email(payload: EmailConfirm, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||||
"""Активируем почту, если код совпал."""
|
"""Активируем почту, если код совпал."""
|
||||||
|
|
||||||
|
# 1. Повторяем поиск пользователя: это второй шаг флоу подтверждения.
|
||||||
user = db.query(User).filter(User.email == payload.email).first()
|
user = db.query(User).filter(User.email == payload.email).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
|
||||||
|
|
||||||
if not user.email_confirmation_token:
|
if not user.email_confirmation_token:
|
||||||
|
# 2. Если токена нет, значит пользователь не запрашивал письмо.
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Код не запрошен")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Код не запрошен")
|
||||||
|
|
||||||
if user.email_confirmation_token != payload.token:
|
if user.email_confirmation_token != payload.token:
|
||||||
|
# 3. Защищаемся от неправильного кода.
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
|
||||||
|
|
||||||
|
# 4. Отмечаем почту подтверждённой и убираем токен.
|
||||||
mark_confirmed(user, db)
|
mark_confirmed(user, db)
|
||||||
return {"detail": "E-mail подтверждён"}
|
return {"detail": "E-mail подтверждён"}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
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)
|
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_confirmation_token: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
|
||||||
email_confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), 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")
|
current_rank = relationship("Rank", back_populates="pilots")
|
||||||
competencies: Mapped[List["UserCompetency"]] = relationship(
|
competencies: Mapped[List["UserCompetency"]] = relationship(
|
||||||
|
|
|
||||||
|
|
@ -85,3 +85,14 @@ class UserLogin(BaseModel):
|
||||||
|
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserRegister(BaseModel):
|
||||||
|
"""Регистрация нового пилота."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str
|
||||||
|
password: str
|
||||||
|
# Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
|
||||||
|
preferred_branch: Optional[str] = None
|
||||||
|
motivation: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ from app.models.user import User
|
||||||
def issue_confirmation_token(user: User, db: Session) -> str:
|
def issue_confirmation_token(user: User, db: Session) -> str:
|
||||||
"""Генерируем и сохраняем одноразовый код подтверждения."""
|
"""Генерируем и сохраняем одноразовый код подтверждения."""
|
||||||
|
|
||||||
|
# В `token_urlsafe(16)` мы просим secrets создать криптостойкую строку длиной ~22 символа,
|
||||||
|
# которую удобно вводить вручную. Каждый новый вызов полностью обнуляет предыдущий код.
|
||||||
token = token_urlsafe(16)
|
token = token_urlsafe(16)
|
||||||
|
# Сбрасываем флаги подтверждения — это важно, если пользователь запрашивает
|
||||||
|
# повторное письмо: только введя актуальный код, он снова активируется.
|
||||||
user.email_confirmation_token = token
|
user.email_confirmation_token = token
|
||||||
user.is_email_confirmed = False
|
user.is_email_confirmed = False
|
||||||
user.email_confirmed_at = None
|
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:
|
def confirm_email(user: User, db: Session) -> None:
|
||||||
"""Помечаем почту подтверждённой."""
|
"""Помечаем почту подтверждённой."""
|
||||||
|
|
||||||
|
# Флаг `is_email_confirmed` избавляет нас от лишних запросов при каждой авторизации.
|
||||||
user.is_email_confirmed = True
|
user.is_email_confirmed = True
|
||||||
|
# Токен больше не нужен — удаляем его, чтобы предотвратить повторное использование.
|
||||||
user.email_confirmation_token = None
|
user.email_confirmation_token = None
|
||||||
|
# Сохраняем точное время подтверждения: пригодится в аналитике и для аудита.
|
||||||
user.email_confirmed_at = datetime.now(timezone.utc)
|
user.email_confirmed_at = datetime.now(timezone.utc)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
10
frontend/src/app/admin/exit-view/route.ts
Normal file
10
frontend/src/app/admin/exit-view/route.ts
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
10
frontend/src/app/admin/view-as/route.ts
Normal file
10
frontend/src/app/admin/view-as/route.ts
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -12,17 +12,33 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
// Пробуем получить сессию (если пользователь не авторизован, вернётся null).
|
// Пробуем получить сессию (если пользователь не авторизован, вернётся null).
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
// Формируем список пунктов меню в зависимости от роли пользователя.
|
// Сохраняем подсказки, кто сейчас вошёл и включил ли HR режим «просмотра глазами пилота».
|
||||||
const links = session
|
const isHr = session?.role === 'hr';
|
||||||
? [
|
const viewingAsPilot = Boolean(session?.viewAsPilot);
|
||||||
{ href: '/', label: 'Дашборд' },
|
|
||||||
{ href: '/onboarding', label: 'Онбординг' },
|
// Формируем пункты меню в зависимости от текущего режима.
|
||||||
{ href: '/missions', label: 'Миссии' },
|
let links: Array<{ href: string; label: string }> = [];
|
||||||
{ href: '/journal', label: 'Журнал' },
|
|
||||||
{ href: '/store', label: 'Магазин' },
|
if (!session) {
|
||||||
...(session.role === 'hr' ? [{ href: '/admin', label: 'HR панель' }] : []),
|
links = [{ href: '/login', label: 'Войти' }];
|
||||||
]
|
} else if (isHr && !viewingAsPilot) {
|
||||||
: [{ href: '/login', label: 'Войти' }];
|
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 (
|
return (
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|
@ -52,6 +68,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
{viewingAsPilot && (
|
||||||
|
<span style={{
|
||||||
|
color: '#ffeaa7',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em'
|
||||||
|
}}>
|
||||||
|
режим просмотра пилота
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{session && (
|
{session && (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: 'var(--text-muted)', marginLeft: '1rem' }}>
|
<span style={{ color: 'var(--text-muted)', marginLeft: '1rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
const existing = await getSession();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -51,6 +55,7 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = searchParams.error;
|
const message = searchParams.error;
|
||||||
|
const info = searchParams.info;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.container}>
|
<section className={styles.container}>
|
||||||
|
|
@ -62,6 +67,7 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
|
||||||
<strong> hr@alabuga.space / orbita123</strong>.
|
<strong> hr@alabuga.space / orbita123</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{info && <p className={styles.info}>{info}</p>}
|
||||||
{message && <p className={styles.error}>{message}</p>}
|
{message && <p className={styles.error}>{message}</p>}
|
||||||
|
|
||||||
<label className={styles.field}>
|
<label className={styles.field}>
|
||||||
|
|
@ -73,6 +79,9 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
|
||||||
<input className={styles.input} type="password" name="password" required placeholder="Введите пароль" />
|
<input className={styles.input} type="password" name="password" required placeholder="Введите пароль" />
|
||||||
</label>
|
</label>
|
||||||
<button className={styles.submit} type="submit">Войти</button>
|
<button className={styles.submit} type="submit">Войти</button>
|
||||||
|
<p className={styles.footer}>
|
||||||
|
Впервые на платформе? <a href="/register">Зарегистрируйтесь и начните путь пилота.</a>
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,16 @@
|
||||||
text-align: center;
|
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 {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
@ -65,3 +75,14 @@
|
||||||
.submit:hover {
|
.submit:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@ import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
export function GET(request: Request) {
|
export function GET(request: Request) {
|
||||||
// Очищаем cookie и мгновенно перенаправляем пользователя на страницу входа.
|
// Очищаем cookie и мгновенно перенаправляем пользователя на страницу входа.
|
||||||
|
// Здесь не используем `destroySession`, потому что `NextResponse` позволяет
|
||||||
|
// выставить заголовки прямо в объекте ответа.
|
||||||
const target = new URL('/login', request.url);
|
const target = new URL('/login', request.url);
|
||||||
const response = NextResponse.redirect(target);
|
const response = NextResponse.redirect(target);
|
||||||
response.cookies.delete('alabuga_session');
|
response.cookies.delete('alabuga_session');
|
||||||
|
response.cookies.delete('alabuga_view_as');
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
frontend/src/app/register/page.tsx
Normal file
105
frontend/src/app/register/page.tsx
Normal file
|
|
@ -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<any>('/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 (
|
||||||
|
<section className={styles.container}>
|
||||||
|
<form className={styles.form} action={registerAction}>
|
||||||
|
<h1>Регистрация пилота</h1>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
После регистрации вы попадёте на онбординг и сможете выполнять миссии.
|
||||||
|
Если включено подтверждение почты, мы отправим код на указанную почту.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{errorMessage && <p className={styles.error}>{errorMessage}</p>}
|
||||||
|
|
||||||
|
<label className={styles.field}>
|
||||||
|
Полное имя
|
||||||
|
<input className={styles.input} name="fullName" required placeholder="Как к вам обращаться" />
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Email
|
||||||
|
<input className={styles.input} type="email" name="email" required placeholder="user@alabuga.space" />
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Пароль
|
||||||
|
<input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Интересующая ветка (необязательно)
|
||||||
|
<select className={styles.input} name="preferredBranch" defaultValue="">
|
||||||
|
<option value="">Выберите ветку</option>
|
||||||
|
<option value="Получение оффера">Получение оффера</option>
|
||||||
|
<option value="Рекрутинг">Рекрутинг</option>
|
||||||
|
<option value="Квесты">Квесты</option>
|
||||||
|
<option value="Симулятор">Симулятор</option>
|
||||||
|
<option value="Лекторий">Лекторий</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Что хотите добиться?
|
||||||
|
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />
|
||||||
|
</label>
|
||||||
|
<button className={styles.submit} type="submit">Создать аккаунт</button>
|
||||||
|
<p className={styles.footer}>
|
||||||
|
Уже есть аккаунт? <a href="/login">Войдите</a>.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/app/register/styles.module.css
Normal file
85
frontend/src/app/register/styles.module.css
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
.container {
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
width: min(520px, 92%);
|
||||||
|
background: rgba(17, 22, 51, 0.85);
|
||||||
|
border: 1px solid rgba(162, 155, 254, 0.35);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2.25rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form h1 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 118, 117, 0.12);
|
||||||
|
border: 1px solid rgba(255, 118, 117, 0.35);
|
||||||
|
color: var(--error);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea,
|
||||||
|
.select {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(162, 155, 254, 0.35);
|
||||||
|
background: rgba(8, 11, 26, 0.7);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(120deg, var(--accent), #00b894);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
@ -11,9 +11,12 @@ interface SessionPayload {
|
||||||
token: string;
|
token: string;
|
||||||
role: 'pilot' | 'hr';
|
role: 'pilot' | 'hr';
|
||||||
fullName: string;
|
fullName: string;
|
||||||
|
viewAsPilot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Названия cookie держим в одном месте, чтобы не допустить опечатки при удалении/чтении.
|
||||||
const SESSION_COOKIE = 'alabuga_session';
|
const SESSION_COOKIE = 'alabuga_session';
|
||||||
|
const VIEW_COOKIE = 'alabuga_view_as';
|
||||||
|
|
||||||
function parseSession(raw: string | undefined): SessionPayload | null {
|
function parseSession(raw: string | undefined): SessionPayload | null {
|
||||||
// Cookie может отсутствовать либо быть повреждённой, поэтому парсинг
|
// Cookie может отсутствовать либо быть повреждённой, поэтому парсинг
|
||||||
|
|
@ -38,8 +41,10 @@ export async function getSession(): Promise<SessionPayload | null> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// backend возвращает 401/403 для просроченных или неподтверждённых токенов.
|
||||||
await apiFetch('/auth/me', { authToken: session.token });
|
await apiFetch('/auth/me', { authToken: session.token });
|
||||||
return session;
|
const viewAsPilot = store.get(VIEW_COOKIE)?.value === 'pilot';
|
||||||
|
return { ...session, viewAsPilot };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Session validation failed:', error);
|
console.warn('Session validation failed:', error);
|
||||||
store.delete(SESSION_COOKIE);
|
store.delete(SESSION_COOKIE);
|
||||||
|
|
@ -80,9 +85,28 @@ export function createSession(session: SessionPayload) {
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 60 * 60 * 12,
|
maxAge: 60 * 60 * 12,
|
||||||
});
|
});
|
||||||
|
store.delete(VIEW_COOKIE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function destroySession() {
|
export function destroySession() {
|
||||||
// Удаление cookie при выходе пользователя.
|
// Удаление cookie при выходе пользователя.
|
||||||
cookies().delete(SESSION_COOKIE);
|
const store = cookies();
|
||||||
|
store.delete(SESSION_COOKIE);
|
||||||
|
store.delete(VIEW_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enablePilotView(): void {
|
||||||
|
// HR включает режим просмотра интерфейса пилота, чтобы видеть клиентские экраны.
|
||||||
|
cookies().set(VIEW_COOKIE, 'pilot', {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disablePilotView(): void {
|
||||||
|
// Возвращаем интерфейс HR к обычному виду.
|
||||||
|
cookies().delete(VIEW_COOKIE);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,9 @@ def seed() -> None:
|
||||||
hashed_password=get_password_hash("orbita123"),
|
hashed_password=get_password_hash("orbita123"),
|
||||||
current_rank_id=ranks[0].id,
|
current_rank_id=ranks[0].id,
|
||||||
is_email_confirmed=True,
|
is_email_confirmed=True,
|
||||||
|
# Эти два поля демонстрируют, как HR видит пожелания кандидата.
|
||||||
|
preferred_branch="Получение оффера",
|
||||||
|
motivation="Хочу пройти все миссии и закрепиться в экипаже.",
|
||||||
)
|
)
|
||||||
hr = User(
|
hr = User(
|
||||||
email="hr@alabuga.space",
|
email="hr@alabuga.space",
|
||||||
|
|
@ -242,6 +245,8 @@ def seed() -> None:
|
||||||
hashed_password=get_password_hash("orbita123"),
|
hashed_password=get_password_hash("orbita123"),
|
||||||
current_rank_id=ranks[2].id,
|
current_rank_id=ranks[2].id,
|
||||||
is_email_confirmed=True,
|
is_email_confirmed=True,
|
||||||
|
# Для HR поле также используем — служит подсказкой в профиле.
|
||||||
|
preferred_branch="Куратор миссий",
|
||||||
)
|
)
|
||||||
session.add_all([pilot, hr])
|
session.add_all([pilot, hr])
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user