diff --git a/web-react/src/App.tsx b/web-react/src/App.tsx index faa2a74..9fd2698 100644 --- a/web-react/src/App.tsx +++ b/web-react/src/App.tsx @@ -1,9 +1,10 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider } from "./auth/AuthContext"; import { ThemeProvider } from "./theme/ThemeContext"; -import { ProtectedRoute } from "./components/ProtectedRoute"; +import { ProtectedRoute, AccountRoute } from "./components/ProtectedRoute"; import { LoginPage } from "./pages/LoginPage"; import { RegisterPage } from "./pages/RegisterPage"; +import { AnonymousEntryPage } from "./pages/AnonymousEntryPage"; import { HomePage } from "./pages/HomePage"; import { TestListPage } from "./pages/TestListPage"; import { PollPage } from "./pages/PollPage"; @@ -11,10 +12,16 @@ import { MyTestsPage } from "./pages/MyTestsPage"; import { ResponsePage } from "./pages/ResponsePage"; import { ProfilePage } from "./pages/ProfilePage"; +// Доступно и по токену, и анонимно (школа+класс без аккаунта). function protect(el: React.ReactNode) { return {el}; } +// Требует настоящий аккаунт — история и профиль анонимусам недоступны. +function account(el: React.ReactNode) { + return {el}; +} + export default function App() { return ( @@ -23,12 +30,13 @@ export default function App() { } /> } /> + } /> )} /> )} /> )} /> - )} /> - )} /> - )} /> + )} /> + )} /> + )} /> } /> diff --git a/web-react/src/auth/AuthContext.tsx b/web-react/src/auth/AuthContext.tsx index 321f0f0..a8dafdc 100644 --- a/web-react/src/auth/AuthContext.tsx +++ b/web-react/src/auth/AuthContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from " import * as api from "../api/client"; const TOKEN_KEY = "auth_token"; +const ANON_KEY = "anon_identity"; export interface AuthUser { id: string; @@ -11,15 +12,38 @@ export interface AuthUser { lastName: string; } +// Анонимная сессия: школьник выбрал школу+класс, но не регистрировался — +// аккаунта и токена нет, только org/group для снимка в Response. +export interface AnonIdentity { + orgId: string; + groupId: string; +} + interface AuthCtx { token: string | null; user: AuthUser | null; + anon: AnonIdentity | null; ready: boolean; // завершена ли первичная проверка токена login: (username: string, password: string) => Promise; + loginAnonymous: (orgId: string, groupId: string) => void; logout: () => Promise; setUserFromMe: (me: api.Me) => void; } +function readAnon(): AnonIdentity | null { + const raw = localStorage.getItem(ANON_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.orgId === "string" && typeof parsed.groupId === "string") { + return parsed; + } + } catch { + // повреждённое значение — игнорируем + } + return null; +} + const Ctx = createContext(null); function meToUser(me: api.Me): AuthUser { @@ -35,6 +59,7 @@ function meToUser(me: api.Me): AuthUser { export function AuthProvider({ children }: { children: ReactNode }) { const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); const [user, setUser] = useState(null); + const [anon, setAnon] = useState(() => readAnon()); const [ready, setReady] = useState(false); // При старте: если есть токен — проверяем его через /users/me @@ -65,6 +90,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const login = async (username: string, password: string): Promise => { const { data, error } = await api.login(username, password); if (error || !data) return error ?? "Ошибка входа"; + localStorage.removeItem(ANON_KEY); + setAnon(null); localStorage.setItem(TOKEN_KEY, data); setToken(data); const me = await api.getMe(data); @@ -72,17 +99,26 @@ export function AuthProvider({ children }: { children: ReactNode }) { return null; }; + // Анонимный вход: только школа+класс, без аккаунта и запроса к API. + const loginAnonymous = (orgId: string, groupId: string) => { + const identity: AnonIdentity = { orgId, groupId }; + localStorage.setItem(ANON_KEY, JSON.stringify(identity)); + setAnon(identity); + }; + const logout = async () => { if (token) await api.logout(token); localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(ANON_KEY); setToken(null); setUser(null); + setAnon(null); }; const setUserFromMe = (me: api.Me) => setUser(meToUser(me)); return ( - + {children} ); diff --git a/web-react/src/components/ProtectedRoute.tsx b/web-react/src/components/ProtectedRoute.tsx index 1c403cb..5cad90c 100644 --- a/web-react/src/components/ProtectedRoute.tsx +++ b/web-react/src/components/ProtectedRoute.tsx @@ -3,8 +3,26 @@ import type { ReactNode } from "react"; import { useAuth } from "../auth/AuthContext"; import { Spinner } from "./ui"; -// Пускает дальше только при наличии токена. Пока идёт первичная проверка — спиннер. +// Пускает дальше при наличии токена ИЛИ анонимной идентичности (школа+класс, +// без аккаунта). Используется для страниц, которые школьник может пройти +// анонимно: главная, список тестов, прохождение теста. export function ProtectedRoute({ children }: { children: ReactNode }) { + const { token, anon, ready } = useAuth(); + if (!ready) { + return ( +
+ +
+ ); + } + if (!token && !anon) return ; + return <>{children}; +} + +// Строже: пускает дальше только при реальном токене (не анонимной сессии). +// Для страниц, которым нужен настоящий аккаунт: история тестов, профиль, +// просмотр конкретного результата. +export function AccountRoute({ children }: { children: ReactNode }) { const { token, ready } = useAuth(); if (!ready) { return ( diff --git a/web-react/src/pages/AnonymousEntryPage.tsx b/web-react/src/pages/AnonymousEntryPage.tsx new file mode 100644 index 0000000..6fe7232 --- /dev/null +++ b/web-react/src/pages/AnonymousEntryPage.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import * as api from "../api/client"; +import { Button } from "../components/ui"; +import { SearchableDropdown, type Option } from "../components/SearchableDropdown"; +import { useAuth } from "../auth/AuthContext"; + +// Вход без регистрации: школьник выбирает только школу и класс, аккаунт +// и пароль не нужны. Результат теста запишется в базу со снимком +// организации/группы, но без привязки к пользователю. +export function AnonymousEntryPage() { + const navigate = useNavigate(); + const { loginAnonymous } = useAuth(); + + const [orgId, setOrgId] = useState(null); + const [groupId, setGroupId] = useState(null); + const [orgs, setOrgs] = useState([]); + const [groups, setGroups] = useState([]); + const [error, setError] = useState(""); + + useEffect(() => { + let cancelled = false; + (async () => { + const [o, g] = await Promise.all([api.getOrganizations(), api.getGroups()]); + if (cancelled) return; + setOrgs(o); + setGroups(g); + })(); + return () => { + cancelled = true; + }; + }, []); + + const onOrgChange = (id: string | null) => { + setOrgId(id); + const org = orgs.find((o) => o.id === id); + if (org?.group_id) setGroupId(org.group_id); + }; + + const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization })); + const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group })); + + const proceed = () => { + if (!orgId || !groupId) { + setError("Выберите школу и класс"); + return; + } + loginAnonymous(orgId, groupId); + navigate("/"); + }; + + return ( +
+
ЦОПП
+
Пройти тест без регистрации — выберите школу и класс
+
{ + e.preventDefault(); + proceed(); + }} + > + + + {error &&
{error}
} +
+ + +
+ +
+ ); +} diff --git a/web-react/src/pages/HomePage.tsx b/web-react/src/pages/HomePage.tsx index bb5bd94..6ed308c 100644 --- a/web-react/src/pages/HomePage.tsx +++ b/web-react/src/pages/HomePage.tsx @@ -1,20 +1,37 @@ import { useNavigate } from "react-router-dom"; import { Layout } from "../components/Layout"; +import { useAuth } from "../auth/AuthContext"; export function HomePage() { const navigate = useNavigate(); + const { token, anon, logout } = useAuth(); + const isAnonymous = !token && !!anon; + + const exitAnonymous = async () => { + await logout(); + navigate("/login"); + }; + return ( - +
navigate("/tests")}> Все тесты
-
navigate("/my-tests")}> - - Мои тесты -
+ {/* «Мои тесты» требует аккаунта — у анонимной сессии нет истории */} + {!isAnonymous && ( +
navigate("/my-tests")}> + + Мои тесты +
+ )}
+ {isAnonymous && ( + + )}
); } diff --git a/web-react/src/pages/LoginPage.tsx b/web-react/src/pages/LoginPage.tsx index 4954355..1e36d79 100644 --- a/web-react/src/pages/LoginPage.tsx +++ b/web-react/src/pages/LoginPage.tsx @@ -66,6 +66,9 @@ export function LoginPage() { + {askReg && ( diff --git a/web-react/src/pages/PollPage.tsx b/web-react/src/pages/PollPage.tsx index 8f87e73..16bce57 100644 --- a/web-react/src/pages/PollPage.tsx +++ b/web-react/src/pages/PollPage.tsx @@ -18,7 +18,7 @@ type Phase = export function PollPage() { const { pollId = "" } = useParams(); const navigate = useNavigate(); - const { user } = useAuth(); + const { user, anon } = useAuth(); const [questions, setQuestions] = useState([]); const [answers, setAnswers] = useState>({}); @@ -73,8 +73,8 @@ export function PollPage() { })); const { data: responseId, error } = await api.submitResponse(pollId, payload, { user_id: user?.id, - group_id: user?.groupId || null, - organization_id: user?.orgId || null, + group_id: user?.groupId || anon?.groupId || null, + organization_id: user?.orgId || anon?.orgId || null, }); if (error || !responseId) { setPhase({ kind: "error", message: error ?? "Не удалось отправить ответы" });