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

Вход в Mission Control

+ {/* Подсказка для демо-режима, чтобы не искать логин/пароль в README. */} +

+ Используйте демо-учётные записи: candidate@alabuga.space / orbita123 или + hr@alabuga.space / orbita123. +

+ + {message &&

{message}

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