Separation users roles

This commit is contained in:
danilgryaznev 2025-09-27 14:53:16 +03:00
parent 0420e6c9e0
commit 4b228a9a46
14 changed files with 333 additions and 95 deletions

View File

@ -78,11 +78,12 @@ npm run dev
## Проверка функционала ## Проверка функционала
1. **Онбординг и лор**: перейдите в `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохранится и откроет доступ к веткам миссий. 1. **Вход**: откройте `/login`, авторизуйтесь как пилот (`candidate@alabuga.space / orbita123`) или HR (`hr@alabuga.space / orbita123`). После успешного входа пилот попадает на дашборд, HR — в админ-панель.
2. **Кандидат**: авторизуйтесь под пилотом, изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий. 2. **Онбординг и лор**: под пилотом посетите `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохраняется и открывает ветки миссий.
3. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически. 3. **Кандидат**: изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
4. **HR панель**: под пользователем `hr@alabuga.space` проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). 4. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
5. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR. 5. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`).
6. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
## Тестирование ## Тестирование

View File

@ -4,7 +4,7 @@ import { AdminRankManager } from '../../components/admin/AdminRankManager';
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager'; import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard'; import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth'; import { requireRole } from '../../lib/auth/session';
interface Submission { interface Submission {
id: number; id: number;
@ -78,16 +78,17 @@ interface AdminStats {
} }
export default async function AdminPage() { export default async function AdminPage() {
const token = await getDemoToken('hr'); // Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
const session = await requireRole('hr');
const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([ const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([
apiFetch<Submission[]>('/api/admin/submissions', { authToken: token }), apiFetch<Submission[]>('/api/admin/submissions', { authToken: session.token }),
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: token }), apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: token }), apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: token }), apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: token }), apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token }), apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: session.token }),
apiFetch<AdminStats>('/api/admin/stats', { authToken: token }) apiFetch<AdminStats>('/api/admin/stats', { authToken: session.token })
]); ]);
return ( return (
@ -145,23 +146,23 @@ export default async function AdminPage() {
</p> </p>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}> <div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
{submissions.map((submission) => ( {submissions.map((submission) => (
<AdminSubmissionCard key={submission.id} submission={submission} token={token} /> <AdminSubmissionCard key={submission.id} submission={submission} token={session.token} />
))} ))}
{submissions.length === 0 && <p>Очередь пуста все миссии проверены.</p>} {submissions.length === 0 && <p>Очередь пуста все миссии проверены.</p>}
</div> </div>
</div> </div>
<AdminBranchManager token={token} branches={branches} /> <AdminBranchManager token={session.token} branches={branches} />
<AdminMissionManager <AdminMissionManager
token={token} token={session.token}
missions={missions} missions={missions}
branches={branches} branches={branches}
ranks={ranks} ranks={ranks}
competencies={competencies} competencies={competencies}
artifacts={artifacts} artifacts={artifacts}
/> />
<AdminRankManager token={token} ranks={ranks} missions={missions} competencies={competencies} /> <AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
<AdminArtifactManager token={token} artifacts={artifacts} /> <AdminArtifactManager token={session.token} artifacts={artifacts} />
</div> </div>
</section> </section>
); );

View File

@ -1,5 +1,5 @@
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth'; import { requireSession } from '../../lib/auth/session';
import { JournalTimeline } from '../../components/JournalTimeline'; import { JournalTimeline } from '../../components/JournalTimeline';
interface JournalEntry { interface JournalEntry {
@ -24,8 +24,7 @@ interface LeaderboardResponse {
entries: LeaderboardEntry[]; entries: LeaderboardEntry[];
} }
async function fetchJournal() { async function fetchJournal(token: string) {
const token = await getDemoToken();
const [entries, week, month, year] = await Promise.all([ const [entries, week, month, year] = await Promise.all([
apiFetch<JournalEntry[]>('/api/journal/', { authToken: token }), apiFetch<JournalEntry[]>('/api/journal/', { authToken: token }),
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=week', { authToken: token }), apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=week', { authToken: token }),
@ -36,7 +35,9 @@ async function fetchJournal() {
} }
export default async function JournalPage() { export default async function JournalPage() {
const { entries, leaderboards } = await fetchJournal(); // Журнал событий содержит персональные данные, поэтому требует активной сессии.
const session = await requireSession();
const { entries, leaderboards } = await fetchJournal(session.token);
return ( return (
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', gap: '2rem' }}> <section className="grid" style={{ gridTemplateColumns: '2fr 1fr', gap: '2rem' }}>

View File

@ -1,13 +1,29 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import StyledComponentsRegistry from '../lib/styled-registry'; import StyledComponentsRegistry from '../lib/styled-registry';
import '../styles/globals.css'; import '../styles/globals.css';
import { getSession } from '../lib/auth/session';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Alabuga Mission Control', title: 'Alabuga Mission Control',
description: 'Космический модуль геймификации для пилотов Алабуги' description: 'Космический модуль геймификации для пилотов Алабуги'
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Пробуем получить сессию (если пользователь не авторизован, вернётся 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: 'Войти' }];
return ( return (
<html lang="ru"> <html lang="ru">
<body style={{ backgroundAttachment: 'fixed' }}> <body style={{ backgroundAttachment: 'fixed' }}>
@ -31,12 +47,21 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</p> </p>
</div> </div>
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}> <nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
<a href="/">Дашборд</a> {links.map((link) => (
<a href="/onboarding">Онбординг</a> <a key={link.href} href={link.href}>
<a href="/missions">Миссии</a> {link.label}
<a href="/journal">Журнал</a> </a>
<a href="/store">Магазин</a> ))}
<a href="/admin">HR Панель</a> {session && (
<>
<span style={{ color: 'var(--text-muted)', marginLeft: '1rem' }}>
{session.fullName} · {session.role === 'hr' ? 'HR' : 'Пилот'}
</span>
<a href="/logout" style={{ fontWeight: 600 }}>
Выйти
</a>
</>
)}
</nav> </nav>
</header> </header>
<main>{children}</main> <main>{children}</main>

View File

@ -0,0 +1,75 @@
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 authenticate(formData: FormData) {
'use server';
const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? '').trim();
if (!email || !password) {
redirect('/login?error=' + encodeURIComponent('Введите email и пароль.'));
}
try {
// 1. Запрашиваем у backend JWT.
const { access_token: token } = await apiFetch<{ access_token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
// 2. Валидируем токен и получаем роль/имя для приветствия.
const profile = await apiFetch<{ full_name: string; role: 'pilot' | 'hr' }>(
'/auth/me',
{ authToken: token }
);
// 3. Сохраняем токен в httpOnly-cookie, чтобы браузер запомнил сессию.
createSession({ token, role: profile.role, fullName: profile.full_name });
// 4. Перенаправляем пользователя на подходящий раздел.
redirect(profile.role === 'hr' ? '/admin' : '/');
} catch (error) {
console.error('Login failed:', error);
redirect('/login?error=' + encodeURIComponent('Неверный email или пароль. Попробуйте ещё раз.'));
}
}
export default async function LoginPage({ searchParams }: { searchParams: { error?: string } }) {
// Если пользователь уже вошёл, сразу перенаправляем его на рабочую страницу.
const existing = await getSession();
if (existing) {
redirect(existing.role === 'hr' ? '/admin' : '/');
}
const message = searchParams.error;
return (
<section className={styles.container}>
<form className={styles.form} action={authenticate}>
<h1>Вход в Mission Control</h1>
{/* Подсказка для демо-режима, чтобы не искать логин/пароль в README. */}
<p className={styles.hint}>
Используйте демо-учётные записи: <strong>candidate@alabuga.space / orbita123</strong> или
<strong> hr@alabuga.space / orbita123</strong>.
</p>
{message && <p className={styles.error}>{message}</p>}
<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>
<button className={styles.submit} type="submit">Войти</button>
</form>
</section>
);
}

View File

@ -0,0 +1,67 @@
.container {
min-height: calc(100vh - 200px);
display: flex;
justify-content: center;
align-items: center;
}
.form {
width: min(420px, 90%);
background: rgba(17, 22, 51, 0.85);
border: 1px solid rgba(162, 155, 254, 0.35);
border-radius: 20px;
padding: 2rem;
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;
}
.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 {
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;
}
.submit {
margin-top: 0.5rem;
padding: 0.85rem 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;
}

View File

@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
export function GET(request: Request) {
// Очищаем cookie и мгновенно перенаправляем пользователя на страницу входа.
const target = new URL('/login', request.url);
const response = NextResponse.redirect(target);
response.cookies.delete('alabuga_session');
return response;
}

View File

@ -1,5 +1,5 @@
import { apiFetch } from '../../../lib/api'; import { apiFetch } from '../../../lib/api';
import { getDemoToken } from '../../../lib/demo-auth'; import { requireSession } from '../../../lib/auth/session';
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm'; import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
interface MissionDetail { interface MissionDetail {
@ -21,10 +21,9 @@ interface MissionDetail {
locked_reasons: string[]; locked_reasons: string[];
} }
async function fetchMission(id: number) { async function fetchMission(id: number, token: string) {
const token = await getDemoToken();
const mission = await apiFetch<MissionDetail>(`/api/missions/${id}`, { authToken: token }); const mission = await apiFetch<MissionDetail>(`/api/missions/${id}`, { authToken: token });
return { mission, token }; return { mission };
} }
interface MissionPageProps { interface MissionPageProps {
@ -33,7 +32,9 @@ interface MissionPageProps {
export default async function MissionPage({ params }: MissionPageProps) { export default async function MissionPage({ params }: MissionPageProps) {
const missionId = Number(params.id); const missionId = Number(params.id);
const { mission, token } = await fetchMission(missionId); // Даже при прямом переходе на URL миссия доступна только авторизованным пользователям.
const session = await requireSession();
const { mission } = await fetchMission(missionId, session.token);
return ( return (
<section> <section>
@ -64,7 +65,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>} {mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
</ul> </ul>
</div> </div>
<MissionSubmissionForm missionId={mission.id} token={token} locked={!mission.is_available} /> <MissionSubmissionForm missionId={mission.id} token={session.token} locked={!mission.is_available} />
</section> </section>
); );
} }

View File

@ -1,5 +1,5 @@
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth'; import { requireSession } from '../../lib/auth/session';
import { MissionList, MissionSummary } from '../../components/MissionList'; import { MissionList, MissionSummary } from '../../components/MissionList';
interface BranchMission { interface BranchMission {
@ -20,8 +20,7 @@ interface BranchOverview {
completed_missions: number; completed_missions: number;
} }
async function fetchMissions() { async function fetchMissions(token: string) {
const token = await getDemoToken();
const [missions, branches] = await Promise.all([ const [missions, branches] = await Promise.all([
apiFetch<MissionSummary[]>('/api/missions/', { authToken: token }), apiFetch<MissionSummary[]>('/api/missions/', { authToken: token }),
apiFetch<BranchOverview[]>('/api/missions/branches', { authToken: token }) apiFetch<BranchOverview[]>('/api/missions/branches', { authToken: token })
@ -30,7 +29,9 @@ async function fetchMissions() {
} }
export default async function MissionsPage() { export default async function MissionsPage() {
const { missions, branches } = await fetchMissions(); // Пилоты видят миссии только после авторизации: гостям покажем страницу входа.
const session = await requireSession();
const { missions, branches } = await fetchMissions(session.token);
return ( return (
<section> <section>

View File

@ -1,5 +1,5 @@
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth'; import { requireSession } from '../../lib/auth/session';
import { OnboardingCarousel, OnboardingSlide } from '../../components/OnboardingCarousel'; import { OnboardingCarousel, OnboardingSlide } from '../../components/OnboardingCarousel';
interface OnboardingState { interface OnboardingState {
@ -13,20 +13,21 @@ interface OnboardingResponse {
next_order: number | null; next_order: number | null;
} }
async function fetchOnboarding() { async function fetchOnboarding(token: string) {
const token = await getDemoToken();
const data = await apiFetch<OnboardingResponse>('/api/onboarding/', { authToken: token }); const data = await apiFetch<OnboardingResponse>('/api/onboarding/', { authToken: token });
return { token, data }; return { data };
} }
export default async function OnboardingPage() { export default async function OnboardingPage() {
const { token, data } = await fetchOnboarding(); // Онбординг доступен только после входа: гостям сразу показываем форму логина.
const session = await requireSession();
const { data } = await fetchOnboarding(session.token);
return ( return (
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', alignItems: 'start', gap: '2rem' }}> <section className="grid" style={{ gridTemplateColumns: '2fr 1fr', alignItems: 'start', gap: '2rem' }}>
<div> <div>
<OnboardingCarousel <OnboardingCarousel
token={token} token={session.token}
slides={data.slides} slides={data.slides}
initialCompletedOrder={data.state.last_completed_order} initialCompletedOrder={data.state.last_completed_order}
nextOrder={data.next_order} nextOrder={data.next_order}
@ -47,4 +48,3 @@ export default async function OnboardingPage() {
</section> </section>
); );
} }

View File

@ -1,5 +1,5 @@
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { getDemoToken } from '../lib/demo-auth'; import { requireSession } from '../lib/auth/session';
import { ProgressOverview } from '../components/ProgressOverview'; import { ProgressOverview } from '../components/ProgressOverview';
interface ProfileResponse { interface ProfileResponse {
@ -41,18 +41,20 @@ interface ProgressResponse {
total_competencies: number; total_competencies: number;
} }
async function fetchProfile() { async function fetchProfile(token: string) {
const token = await getDemoToken();
const [profile, progress] = await Promise.all([ const [profile, progress] = await Promise.all([
apiFetch<ProfileResponse>('/api/me', { authToken: token }), apiFetch<ProfileResponse>('/api/me', { authToken: token }),
apiFetch<ProgressResponse>('/api/progress', { authToken: token }) apiFetch<ProgressResponse>('/api/progress', { authToken: token })
]); ]);
return { token, profile, progress }; return { profile, progress };
} }
export default async function DashboardPage() { export default async function DashboardPage() {
const { token, profile, progress } = await fetchProfile(); // Стартовая страница доступна только авторизованным пользователям (пилотам).
// Если сессия отсутствует, `requireSession` автоматически выполнит редирект на `/login`.
const session = await requireSession();
const { profile, progress } = await fetchProfile(session.token);
return ( return (
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}> <section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
@ -75,7 +77,9 @@ export default async function DashboardPage() {
Осталось {progress.xp.remaining} XP · {progress.completed_missions}/{progress.total_missions} миссий ·{' '} Осталось {progress.xp.remaining} XP · {progress.completed_missions}/{progress.total_missions} миссий ·{' '}
{progress.met_competencies}/{progress.total_competencies} компетенций. {progress.met_competencies}/{progress.total_competencies} компетенций.
</p> </p>
<p style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p> <p style={{ marginTop: '1rem' }}>
Доступ к HR-панели: {session.role === 'hr' ? 'есть' : 'нет'}
</p>
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions"> <a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">
Посмотреть миссии Посмотреть миссии
</a> </a>

View File

@ -1,5 +1,5 @@
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth'; import { requireSession } from '../../lib/auth/session';
import { StoreItems } from '../../components/StoreItems'; import { StoreItems } from '../../components/StoreItems';
interface StoreItem { interface StoreItem {
@ -10,14 +10,15 @@ interface StoreItem {
stock: number; stock: number;
} }
async function fetchStore() { async function fetchStore(token: string) {
const token = await getDemoToken();
const items = await apiFetch<StoreItem[]>('/api/store/items', { authToken: token }); const items = await apiFetch<StoreItem[]>('/api/store/items', { authToken: token });
return { items, token }; return { items };
} }
export default async function StorePage() { export default async function StorePage() {
const { items, token } = await fetchStore(); // Витрина открыта только для членов экипажа: гостям необходимо авторизоваться.
const session = await requireSession();
const { items } = await fetchStore(session.token);
return ( return (
<section> <section>
@ -25,7 +26,7 @@ export default async function StorePage() {
<p style={{ color: 'var(--text-muted)' }}> <p style={{ color: 'var(--text-muted)' }}>
Обменивайте ману на уникальные впечатления и мерч. Доступно только для активных членов экипажа. Обменивайте ману на уникальные впечатления и мерч. Доступно только для активных членов экипажа.
</p> </p>
<StoreItems items={items} token={token} /> <StoreItems items={items} token={session.token} />
</section> </section>
); );
} }

View File

@ -0,0 +1,88 @@
// В этом модуле собраны утилиты для работы с сессией пользователя на сервере.
// Мы храним JWT и базовую информацию о пользователе в httpOnly-cookie,
// чтобы управлять правами доступа без дублирования логики на каждом экране.
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { apiFetch } from '../api';
interface SessionPayload {
token: string;
role: 'pilot' | 'hr';
fullName: string;
}
const SESSION_COOKIE = 'alabuga_session';
function parseSession(raw: string | undefined): SessionPayload | null {
// Cookie может отсутствовать либо быть повреждённой, поэтому парсинг
// оборачиваем в try/catch и возвращаем null, если что-то пошло не так.
if (!raw) return null;
try {
return JSON.parse(raw) as SessionPayload;
} catch (error) {
console.error('Failed to parse session cookie:', error);
return null;
}
}
export async function getSession(): Promise<SessionPayload | null> {
// Читаем cookie и, если токен действительно есть, дополнительно проверяем его
// на сервере вызовом `/auth/me`. Так мы гарантируем, что вернём только
// актуальные сессии, а устаревшие токены удалим.
const store = cookies();
const session = parseSession(store.get(SESSION_COOKIE)?.value);
if (!session) {
return null;
}
try {
await apiFetch('/auth/me', { authToken: session.token });
return session;
} catch (error) {
console.warn('Session validation failed:', error);
store.delete(SESSION_COOKIE);
return null;
}
}
export async function requireSession(): Promise<SessionPayload> {
// В случае отсутствия сессии сразу отправляем пользователя на страницу входа.
const session = await getSession();
if (!session) {
redirect('/login');
}
return session;
}
export async function requireRole(role: SessionPayload['role']): Promise<SessionPayload> {
// Дополнительная проверка права доступа: если роль не совпадает, выполняем
// безопасный редирект на подходящую страницу (пилоты возвращаются на дашборд,
// а HR — в админку).
const session = await requireSession();
if (session.role !== role) {
if (role === 'hr') {
redirect('/');
}
redirect('/admin');
}
return session;
}
export function createSession(session: SessionPayload) {
// Сохраняем данные в httpOnly-cookie, чтобы клиентский JavaScript не имел к ним доступа.
const store = cookies();
store.set(SESSION_COOKIE, JSON.stringify(session), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 12,
});
}
export function destroySession() {
// Удаление cookie при выходе пользователя.
cookies().delete(SESSION_COOKIE);
}

View File

@ -1,36 +0,0 @@
import { apiFetch } from './api';
type DemoRole = 'pilot' | 'hr';
const tokenCache: Partial<Record<DemoRole, string>> = {};
function resolveCredentials(role: DemoRole) {
if (role === 'hr') {
const email = process.env.NEXT_PUBLIC_DEMO_HR_EMAIL ?? 'hr@alabuga.space';
const password =
process.env.NEXT_PUBLIC_DEMO_HR_PASSWORD ?? process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123';
return { email, password };
}
return {
email: process.env.NEXT_PUBLIC_DEMO_EMAIL ?? 'candidate@alabuga.space',
password: process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123'
};
}
export async function getDemoToken(role: DemoRole = 'pilot') {
const cachedToken = tokenCache[role];
if (cachedToken) {
return cachedToken;
}
const credentials = resolveCredentials(role);
const data = await apiFetch<{ access_token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials)
});
tokenCache[role] = data.access_token;
return data.access_token;
}