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 — в админ-панель.
|
||||
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; при недостатке маны интерфейс подскажет, что делать.
|
||||
|
||||
### Подтверждение электронной почты
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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 подтверждён"}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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).
|
||||
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 (
|
||||
<html lang="ru">
|
||||
|
|
@ -52,6 +68,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
{viewingAsPilot && (
|
||||
<span style={{
|
||||
color: '#ffeaa7',
|
||||
fontSize: '0.85rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
режим просмотра пилота
|
||||
</span>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<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();
|
||||
if (existing) {
|
||||
|
|
@ -51,6 +55,7 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
|
|||
}
|
||||
|
||||
const message = searchParams.error;
|
||||
const info = searchParams.info;
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
|
|
@ -62,6 +67,7 @@ export default async function LoginPage({ searchParams }: { searchParams: { erro
|
|||
<strong> hr@alabuga.space / orbita123</strong>.
|
||||
</p>
|
||||
|
||||
{info && <p className={styles.info}>{info}</p>}
|
||||
{message && <p className={styles.error}>{message}</p>}
|
||||
|
||||
<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="Введите пароль" />
|
||||
</label>
|
||||
<button className={styles.submit} type="submit">Войти</button>
|
||||
<p className={styles.footer}>
|
||||
Впервые на платформе? <a href="/register">Зарегистрируйтесь и начните путь пилота.</a>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
role: 'pilot' | 'hr';
|
||||
fullName: string;
|
||||
viewAsPilot?: boolean;
|
||||
}
|
||||
|
||||
// Названия cookie держим в одном месте, чтобы не допустить опечатки при удалении/чтении.
|
||||
const SESSION_COOKIE = 'alabuga_session';
|
||||
const VIEW_COOKIE = 'alabuga_view_as';
|
||||
|
||||
function parseSession(raw: string | undefined): SessionPayload | null {
|
||||
// Cookie может отсутствовать либо быть повреждённой, поэтому парсинг
|
||||
|
|
@ -38,8 +41,10 @@ export async function getSession(): Promise<SessionPayload | null> {
|
|||
}
|
||||
|
||||
try {
|
||||
// backend возвращает 401/403 для просроченных или неподтверждённых токенов.
|
||||
await apiFetch('/auth/me', { authToken: session.token });
|
||||
return session;
|
||||
const viewAsPilot = store.get(VIEW_COOKIE)?.value === 'pilot';
|
||||
return { ...session, viewAsPilot };
|
||||
} catch (error) {
|
||||
console.warn('Session validation failed:', error);
|
||||
store.delete(SESSION_COOKIE);
|
||||
|
|
@ -80,9 +85,28 @@ export function createSession(session: SessionPayload) {
|
|||
path: '/',
|
||||
maxAge: 60 * 60 * 12,
|
||||
});
|
||||
store.delete(VIEW_COOKIE);
|
||||
}
|
||||
|
||||
export function destroySession() {
|
||||
// Удаление 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"),
|
||||
current_rank_id=ranks[0].id,
|
||||
is_email_confirmed=True,
|
||||
# Эти два поля демонстрируют, как HR видит пожелания кандидата.
|
||||
preferred_branch="Получение оффера",
|
||||
motivation="Хочу пройти все миссии и закрепиться в экипаже.",
|
||||
)
|
||||
hr = User(
|
||||
email="hr@alabuga.space",
|
||||
|
|
@ -242,6 +245,8 @@ def seed() -> None:
|
|||
hashed_password=get_password_hash("orbita123"),
|
||||
current_rank_id=ranks[2].id,
|
||||
is_email_confirmed=True,
|
||||
# Для HR поле также используем — служит подсказкой в профиле.
|
||||
preferred_branch="Куратор миссий",
|
||||
)
|
||||
session.add_all([pilot, hr])
|
||||
session.flush()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user