Separation users roles
This commit is contained in:
parent
0420e6c9e0
commit
4b228a9a46
11
README.md
11
README.md
|
|
@ -78,11 +78,12 @@ npm run dev
|
|||
|
||||
## Проверка функционала
|
||||
|
||||
1. **Онбординг и лор**: перейдите в `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохранится и откроет доступ к веткам миссий.
|
||||
2. **Кандидат**: авторизуйтесь под пилотом, изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
|
||||
3. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
||||
4. **HR панель**: под пользователем `hr@alabuga.space` проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`).
|
||||
5. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR.
|
||||
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; при недостатке маны интерфейс подскажет, что делать.
|
||||
|
||||
## Тестирование
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
|||
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
||||
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
import { requireRole } from '../../lib/auth/session';
|
||||
|
||||
interface Submission {
|
||||
id: number;
|
||||
|
|
@ -78,16 +78,17 @@ interface AdminStats {
|
|||
}
|
||||
|
||||
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([
|
||||
apiFetch<Submission[]>('/api/admin/submissions', { authToken: token }),
|
||||
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: token }),
|
||||
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: token }),
|
||||
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: token }),
|
||||
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: token }),
|
||||
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token }),
|
||||
apiFetch<AdminStats>('/api/admin/stats', { authToken: token })
|
||||
apiFetch<Submission[]>('/api/admin/submissions', { authToken: session.token }),
|
||||
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
|
||||
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
|
||||
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
|
||||
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
|
||||
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: session.token }),
|
||||
apiFetch<AdminStats>('/api/admin/stats', { authToken: session.token })
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
@ -145,23 +146,23 @@ export default async function AdminPage() {
|
|||
</p>
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{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>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminBranchManager token={token} branches={branches} />
|
||||
<AdminBranchManager token={session.token} branches={branches} />
|
||||
<AdminMissionManager
|
||||
token={token}
|
||||
token={session.token}
|
||||
missions={missions}
|
||||
branches={branches}
|
||||
ranks={ranks}
|
||||
competencies={competencies}
|
||||
artifacts={artifacts}
|
||||
/>
|
||||
<AdminRankManager token={token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||
<AdminArtifactManager token={token} artifacts={artifacts} />
|
||||
<AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||
<AdminArtifactManager token={session.token} artifacts={artifacts} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apiFetch } from '../../lib/api';
|
||||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
import { requireSession } from '../../lib/auth/session';
|
||||
import { JournalTimeline } from '../../components/JournalTimeline';
|
||||
|
||||
interface JournalEntry {
|
||||
|
|
@ -24,8 +24,7 @@ interface LeaderboardResponse {
|
|||
entries: LeaderboardEntry[];
|
||||
}
|
||||
|
||||
async function fetchJournal() {
|
||||
const token = await getDemoToken();
|
||||
async function fetchJournal(token: string) {
|
||||
const [entries, week, month, year] = await Promise.all([
|
||||
apiFetch<JournalEntry[]>('/api/journal/', { authToken: token }),
|
||||
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=week', { authToken: token }),
|
||||
|
|
@ -36,7 +35,9 @@ async function fetchJournal() {
|
|||
}
|
||||
|
||||
export default async function JournalPage() {
|
||||
const { entries, leaderboards } = await fetchJournal();
|
||||
// Журнал событий содержит персональные данные, поэтому требует активной сессии.
|
||||
const session = await requireSession();
|
||||
const { entries, leaderboards } = await fetchJournal(session.token);
|
||||
|
||||
return (
|
||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', gap: '2rem' }}>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,29 @@
|
|||
import type { Metadata } from 'next';
|
||||
import StyledComponentsRegistry from '../lib/styled-registry';
|
||||
import '../styles/globals.css';
|
||||
import { getSession } from '../lib/auth/session';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Alabuga Mission Control',
|
||||
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 (
|
||||
<html lang="ru">
|
||||
<body style={{ backgroundAttachment: 'fixed' }}>
|
||||
|
|
@ -31,12 +47,21 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
</p>
|
||||
</div>
|
||||
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
|
||||
<a href="/">Дашборд</a>
|
||||
<a href="/onboarding">Онбординг</a>
|
||||
<a href="/missions">Миссии</a>
|
||||
<a href="/journal">Журнал</a>
|
||||
<a href="/store">Магазин</a>
|
||||
<a href="/admin">HR Панель</a>
|
||||
{links.map((link) => (
|
||||
<a key={link.href} href={link.href}>
|
||||
{link.label}
|
||||
</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>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
|
|
|
|||
75
frontend/src/app/login/page.tsx
Normal file
75
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/app/login/styles.module.css
Normal file
67
frontend/src/app/login/styles.module.css
Normal 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;
|
||||
}
|
||||
9
frontend/src/app/logout/route.ts
Normal file
9
frontend/src/app/logout/route.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { apiFetch } from '../../../lib/api';
|
||||
import { getDemoToken } from '../../../lib/demo-auth';
|
||||
import { requireSession } from '../../../lib/auth/session';
|
||||
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
|
||||
|
||||
interface MissionDetail {
|
||||
|
|
@ -21,10 +21,9 @@ interface MissionDetail {
|
|||
locked_reasons: string[];
|
||||
}
|
||||
|
||||
async function fetchMission(id: number) {
|
||||
const token = await getDemoToken();
|
||||
async function fetchMission(id: number, token: string) {
|
||||
const mission = await apiFetch<MissionDetail>(`/api/missions/${id}`, { authToken: token });
|
||||
return { mission, token };
|
||||
return { mission };
|
||||
}
|
||||
|
||||
interface MissionPageProps {
|
||||
|
|
@ -33,7 +32,9 @@ interface MissionPageProps {
|
|||
|
||||
export default async function MissionPage({ params }: MissionPageProps) {
|
||||
const missionId = Number(params.id);
|
||||
const { mission, token } = await fetchMission(missionId);
|
||||
// Даже при прямом переходе на URL миссия доступна только авторизованным пользователям.
|
||||
const session = await requireSession();
|
||||
const { mission } = await fetchMission(missionId, session.token);
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
|
@ -64,7 +65,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<MissionSubmissionForm missionId={mission.id} token={token} locked={!mission.is_available} />
|
||||
<MissionSubmissionForm missionId={mission.id} token={session.token} locked={!mission.is_available} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apiFetch } from '../../lib/api';
|
||||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
import { requireSession } from '../../lib/auth/session';
|
||||
import { MissionList, MissionSummary } from '../../components/MissionList';
|
||||
|
||||
interface BranchMission {
|
||||
|
|
@ -20,8 +20,7 @@ interface BranchOverview {
|
|||
completed_missions: number;
|
||||
}
|
||||
|
||||
async function fetchMissions() {
|
||||
const token = await getDemoToken();
|
||||
async function fetchMissions(token: string) {
|
||||
const [missions, branches] = await Promise.all([
|
||||
apiFetch<MissionSummary[]>('/api/missions/', { authToken: token }),
|
||||
apiFetch<BranchOverview[]>('/api/missions/branches', { authToken: token })
|
||||
|
|
@ -30,7 +29,9 @@ async function fetchMissions() {
|
|||
}
|
||||
|
||||
export default async function MissionsPage() {
|
||||
const { missions, branches } = await fetchMissions();
|
||||
// Пилоты видят миссии только после авторизации: гостям покажем страницу входа.
|
||||
const session = await requireSession();
|
||||
const { missions, branches } = await fetchMissions(session.token);
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apiFetch } from '../../lib/api';
|
||||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
import { requireSession } from '../../lib/auth/session';
|
||||
import { OnboardingCarousel, OnboardingSlide } from '../../components/OnboardingCarousel';
|
||||
|
||||
interface OnboardingState {
|
||||
|
|
@ -13,20 +13,21 @@ interface OnboardingResponse {
|
|||
next_order: number | null;
|
||||
}
|
||||
|
||||
async function fetchOnboarding() {
|
||||
const token = await getDemoToken();
|
||||
async function fetchOnboarding(token: string) {
|
||||
const data = await apiFetch<OnboardingResponse>('/api/onboarding/', { authToken: token });
|
||||
return { token, data };
|
||||
return { data };
|
||||
}
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const { token, data } = await fetchOnboarding();
|
||||
// Онбординг доступен только после входа: гостям сразу показываем форму логина.
|
||||
const session = await requireSession();
|
||||
const { data } = await fetchOnboarding(session.token);
|
||||
|
||||
return (
|
||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', alignItems: 'start', gap: '2rem' }}>
|
||||
<div>
|
||||
<OnboardingCarousel
|
||||
token={token}
|
||||
token={session.token}
|
||||
slides={data.slides}
|
||||
initialCompletedOrder={data.state.last_completed_order}
|
||||
nextOrder={data.next_order}
|
||||
|
|
@ -47,4 +48,3 @@ export default async function OnboardingPage() {
|
|||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apiFetch } from '../lib/api';
|
||||
import { getDemoToken } from '../lib/demo-auth';
|
||||
import { requireSession } from '../lib/auth/session';
|
||||
import { ProgressOverview } from '../components/ProgressOverview';
|
||||
|
||||
interface ProfileResponse {
|
||||
|
|
@ -41,18 +41,20 @@ interface ProgressResponse {
|
|||
total_competencies: number;
|
||||
}
|
||||
|
||||
async function fetchProfile() {
|
||||
const token = await getDemoToken();
|
||||
async function fetchProfile(token: string) {
|
||||
const [profile, progress] = await Promise.all([
|
||||
apiFetch<ProfileResponse>('/api/me', { authToken: token }),
|
||||
apiFetch<ProgressResponse>('/api/progress', { authToken: token })
|
||||
]);
|
||||
|
||||
return { token, profile, progress };
|
||||
return { profile, progress };
|
||||
}
|
||||
|
||||
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 (
|
||||
<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.met_competencies}/{progress.total_competencies} компетенций.
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apiFetch } from '../../lib/api';
|
||||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
import { requireSession } from '../../lib/auth/session';
|
||||
import { StoreItems } from '../../components/StoreItems';
|
||||
|
||||
interface StoreItem {
|
||||
|
|
@ -10,14 +10,15 @@ interface StoreItem {
|
|||
stock: number;
|
||||
}
|
||||
|
||||
async function fetchStore() {
|
||||
const token = await getDemoToken();
|
||||
async function fetchStore(token: string) {
|
||||
const items = await apiFetch<StoreItem[]>('/api/store/items', { authToken: token });
|
||||
return { items, token };
|
||||
return { items };
|
||||
}
|
||||
|
||||
export default async function StorePage() {
|
||||
const { items, token } = await fetchStore();
|
||||
// Витрина открыта только для членов экипажа: гостям необходимо авторизоваться.
|
||||
const session = await requireSession();
|
||||
const { items } = await fetchStore(session.token);
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
|
@ -25,7 +26,7 @@ export default async function StorePage() {
|
|||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
Обменивайте ману на уникальные впечатления и мерч. Доступно только для активных членов экипажа.
|
||||
</p>
|
||||
<StoreItems items={items} token={token} />
|
||||
<StoreItems items={items} token={session.token} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
88
frontend/src/lib/auth/session.ts
Normal file
88
frontend/src/lib/auth/session.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user