Adding registration doesnt working

This commit is contained in:
danilgryaznev 2025-09-27 22:22:26 +03:00
parent 418c8bdb65
commit 1535453cb7
16 changed files with 425 additions and 24 deletions

View File

@ -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; при недостатке маны интерфейс подскажет, что делать.
### Подтверждение электронной почты ### Подтверждение электронной почты

View File

@ -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')

View File

@ -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 подтверждён"}

View File

@ -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(

View File

@ -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

View File

@ -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()

View 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));
}

View 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));
}

View File

@ -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' }}>

View File

@ -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>
); );

View File

@ -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);
}

View File

@ -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;
} }

View 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>
);
}

View 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);
}

View File

@ -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);
} }

View File

@ -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()