From 4b228a9a46da38e2d019b6791deb5b2555d045de Mon Sep 17 00:00:00 2001
From: danilgryaznev
Date: Sat, 27 Sep 2025 14:53:16 +0300
Subject: [PATCH] Separation users roles
---
README.md | 11 +--
frontend/src/app/admin/page.tsx | 29 ++++----
frontend/src/app/journal/page.tsx | 9 +--
frontend/src/app/layout.tsx | 39 +++++++++--
frontend/src/app/login/page.tsx | 75 ++++++++++++++++++++
frontend/src/app/login/styles.module.css | 67 ++++++++++++++++++
frontend/src/app/logout/route.ts | 9 +++
frontend/src/app/missions/[id]/page.tsx | 13 ++--
frontend/src/app/missions/page.tsx | 9 +--
frontend/src/app/onboarding/page.tsx | 14 ++--
frontend/src/app/page.tsx | 16 +++--
frontend/src/app/store/page.tsx | 13 ++--
frontend/src/lib/auth/session.ts | 88 ++++++++++++++++++++++++
frontend/src/lib/demo-auth.ts | 36 ----------
14 files changed, 333 insertions(+), 95 deletions(-)
create mode 100644 frontend/src/app/login/page.tsx
create mode 100644 frontend/src/app/login/styles.module.css
create mode 100644 frontend/src/app/logout/route.ts
create mode 100644 frontend/src/lib/auth/session.ts
delete mode 100644 frontend/src/lib/demo-auth.ts
diff --git a/README.md b/README.md
index 0855de7..c802fc8 100644
--- a/README.md
+++ b/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; при недостатке маны интерфейс подскажет, что делать.
## Тестирование
diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx
index 07b6f9d..97039a1 100644
--- a/frontend/src/app/admin/page.tsx
+++ b/frontend/src/app/admin/page.tsx
@@ -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('/api/admin/submissions', { authToken: token }),
- apiFetch('/api/admin/missions', { authToken: token }),
- apiFetch('/api/admin/branches', { authToken: token }),
- apiFetch('/api/admin/ranks', { authToken: token }),
- apiFetch('/api/admin/competencies', { authToken: token }),
- apiFetch('/api/admin/artifacts', { authToken: token }),
- apiFetch('/api/admin/stats', { authToken: token })
+ apiFetch('/api/admin/submissions', { authToken: session.token }),
+ apiFetch('/api/admin/missions', { authToken: session.token }),
+ apiFetch('/api/admin/branches', { authToken: session.token }),
+ apiFetch('/api/admin/ranks', { authToken: session.token }),
+ apiFetch('/api/admin/competencies', { authToken: session.token }),
+ apiFetch('/api/admin/artifacts', { authToken: session.token }),
+ apiFetch('/api/admin/stats', { authToken: session.token })
]);
return (
@@ -145,23 +146,23 @@ export default async function AdminPage() {
{submissions.map((submission) => (
-
+
))}
{submissions.length === 0 &&
Очередь пуста — все миссии проверены.
}
-
+
-
-
+
+
);
diff --git a/frontend/src/app/journal/page.tsx b/frontend/src/app/journal/page.tsx
index dcc3513..cd8c4fd 100644
--- a/frontend/src/app/journal/page.tsx
+++ b/frontend/src/app/journal/page.tsx
@@ -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('/api/journal/', { authToken: token }),
apiFetch('/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 (
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 4249975..df9e1ed 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -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 (
@@ -31,12 +47,21 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx
new file mode 100644
index 0000000..7caf5c9
--- /dev/null
+++ b/frontend/src/app/login/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/app/login/styles.module.css b/frontend/src/app/login/styles.module.css
new file mode 100644
index 0000000..f8c69ec
--- /dev/null
+++ b/frontend/src/app/login/styles.module.css
@@ -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;
+}
diff --git a/frontend/src/app/logout/route.ts b/frontend/src/app/logout/route.ts
new file mode 100644
index 0000000..5fd8338
--- /dev/null
+++ b/frontend/src/app/logout/route.ts
@@ -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;
+}
diff --git a/frontend/src/app/missions/[id]/page.tsx b/frontend/src/app/missions/[id]/page.tsx
index 34f45a5..1f6c106 100644
--- a/frontend/src/app/missions/[id]/page.tsx
+++ b/frontend/src/app/missions/[id]/page.tsx
@@ -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(`/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 (
@@ -64,7 +65,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
{mission.competency_rewards.length === 0 && Нет прокачки компетенций.}
-
+
);
}
diff --git a/frontend/src/app/missions/page.tsx b/frontend/src/app/missions/page.tsx
index 4918de2..60bc3dd 100644
--- a/frontend/src/app/missions/page.tsx
+++ b/frontend/src/app/missions/page.tsx
@@ -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('/api/missions/', { authToken: token }),
apiFetch('/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 (
diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/src/app/onboarding/page.tsx
index 76d6f87..46a9969 100644
--- a/frontend/src/app/onboarding/page.tsx
+++ b/frontend/src/app/onboarding/page.tsx
@@ -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('/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 (
);
}
-
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 33c69ce..52bf536 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -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('/api/me', { authToken: token }),
apiFetch('/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 (
@@ -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} компетенций.
- Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}
+
+ Доступ к HR-панели: {session.role === 'hr' ? 'есть' : 'нет'}
+
Посмотреть миссии
diff --git a/frontend/src/app/store/page.tsx b/frontend/src/app/store/page.tsx
index a2c3833..8eecf09 100644
--- a/frontend/src/app/store/page.tsx
+++ b/frontend/src/app/store/page.tsx
@@ -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('/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 (
@@ -25,7 +26,7 @@ export default async function StorePage() {
Обменивайте ману на уникальные впечатления и мерч. Доступно только для активных членов экипажа.
-
+
);
}
diff --git a/frontend/src/lib/auth/session.ts b/frontend/src/lib/auth/session.ts
new file mode 100644
index 0000000..b078964
--- /dev/null
+++ b/frontend/src/lib/auth/session.ts
@@ -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 {
+ // Читаем 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 {
+ // В случае отсутствия сессии сразу отправляем пользователя на страницу входа.
+ const session = await getSession();
+ if (!session) {
+ redirect('/login');
+ }
+ return session;
+}
+
+export async function requireRole(role: SessionPayload['role']): Promise {
+ // Дополнительная проверка права доступа: если роль не совпадает, выполняем
+ // безопасный редирект на подходящую страницу (пилоты возвращаются на дашборд,
+ // а 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);
+}
diff --git a/frontend/src/lib/demo-auth.ts b/frontend/src/lib/demo-auth.ts
deleted file mode 100644
index 1f582e3..0000000
--- a/frontend/src/lib/demo-auth.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { apiFetch } from './api';
-
-type DemoRole = 'pilot' | 'hr';
-
-const tokenCache: Partial> = {};
-
-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;
-}