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`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохранится и откроет доступ к веткам миссий.
|
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; при недостатке маны интерфейс подскажет, что делать.
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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' }}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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