feat(web): анонимный вход для школьников — только школа и класс

Школьники не должны придумывать пароль, чтобы пройти тест. Бэкенд уже
поддерживал это: POST /polls/{poll_id}/responses принимает user_id как
Optional, а Response.group_id/organization_id — самостоятельные nullable
снимки школы/класса, не завязанные на аккаунт. Не хватало только
фронтенда — все роуты были жёстко завёрнуты в ProtectedRoute с
обязательным JWT.

- AuthContext: параллельная "анонимная идентичность" (org+group в
  localStorage, без токена и без запроса к API)
- ProtectedRoute пускает по токену ИЛИ анонимной сессии; новый
  AccountRoute — строго по токену, для /my-tests, /profile,
  /response/:id (у анонимной сессии нет истории — её негде хранить)
- Новая страница /anonymous — выбор школы+класса тем же
  SearchableDropdown, что и в регистрации
- PollPage при отправке ответа берёт org/group из анонимной сессии,
  если пользователь не залогинен
- HomePage скрывает "Мои тесты" и профиль для анонимов, добавлена
  кнопка выхода из анонимного режима

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-16 11:56:08 +05:00
parent dd0c29ccad
commit 94299d4af6
7 changed files with 171 additions and 14 deletions

View File

@@ -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 <ProtectedRoute>{el}</ProtectedRoute>;
}
// Требует настоящий аккаунт — история и профиль анонимусам недоступны.
function account(el: React.ReactNode) {
return <AccountRoute>{el}</AccountRoute>;
}
export default function App() {
return (
<ThemeProvider>
@@ -23,12 +30,13 @@ export default function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/anonymous" element={<AnonymousEntryPage />} />
<Route path="/" element={protect(<HomePage />)} />
<Route path="/tests" element={protect(<TestListPage />)} />
<Route path="/poll/:pollId" element={protect(<PollPage />)} />
<Route path="/my-tests" element={protect(<MyTestsPage />)} />
<Route path="/response/:responseId" element={protect(<ResponsePage />)} />
<Route path="/profile" element={protect(<ProfilePage />)} />
<Route path="/my-tests" element={account(<MyTestsPage />)} />
<Route path="/response/:responseId" element={account(<ResponsePage />)} />
<Route path="/profile" element={account(<ProfilePage />)} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>

View File

@@ -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<string | null>;
loginAnonymous: (orgId: string, groupId: string) => void;
logout: () => Promise<void>;
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<AuthCtx | null>(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<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [user, setUser] = useState<AuthUser | null>(null);
const [anon, setAnon] = useState<AnonIdentity | null>(() => 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<string | null> => {
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 (
<Ctx.Provider value={{ token, user, ready, login, logout, setUserFromMe }}>
<Ctx.Provider value={{ token, user, anon, ready, login, loginAnonymous, logout, setUserFromMe }}>
{children}
</Ctx.Provider>
);

View File

@@ -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 (
<div className="spinner-center">
<Spinner />
</div>
);
}
if (!token && !anon) return <Navigate to="/login" replace />;
return <>{children}</>;
}
// Строже: пускает дальше только при реальном токене (не анонимной сессии).
// Для страниц, которым нужен настоящий аккаунт: история тестов, профиль,
// просмотр конкретного результата.
export function AccountRoute({ children }: { children: ReactNode }) {
const { token, ready } = useAuth();
if (!ready) {
return (

View File

@@ -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<string | null>(null);
const [groupId, setGroupId] = useState<string | null>(null);
const [orgs, setOrgs] = useState<api.Organization[]>([]);
const [groups, setGroups] = useState<api.Group[]>([]);
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 (
<div className="auth-wrap">
<div className="auth-logo">ЦОПП</div>
<div className="auth-sub">Пройти тест без регистрации выберите школу и класс</div>
<form
className="auth-card"
onSubmit={(e) => {
e.preventDefault();
proceed();
}}
>
<SearchableDropdown label="Школа" hint="Выберите школу..." options={orgOptions} value={orgId} onChange={onOrgChange} />
<SearchableDropdown label="Класс" hint="Выберите класс..." options={groupOptions} value={groupId} onChange={setGroupId} />
{error && <div className="error-text">{error}</div>}
<div className="wrap-row">
<Button type="button" variant="ghost" onClick={() => navigate("/login")}>
Назад
</Button>
<Button type="submit">Продолжить</Button>
</div>
</form>
</div>
);
}

View File

@@ -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 (
<Layout title="Добро пожаловать в ЦОПП!">
<Layout title="Добро пожаловать в ЦОПП!" showProfile={!!token}>
<div className="home-grid">
<div className="home-tile" onClick={() => navigate("/tests")}>
<img src="/icons/clipboard-question_16542596.svg" alt="" />
<span>Все тесты</span>
</div>
<div className="home-tile" onClick={() => navigate("/my-tests")}>
<img src="/icons/leaderboard-trophy_14227576.svg" alt="" />
<span>Мои тесты</span>
</div>
{/* «Мои тесты» требует аккаунта — у анонимной сессии нет истории */}
{!isAnonymous && (
<div className="home-tile" onClick={() => navigate("/my-tests")}>
<img src="/icons/leaderboard-trophy_14227576.svg" alt="" />
<span>Мои тесты</span>
</div>
)}
</div>
{isAnonymous && (
<button type="button" className="link-btn" onClick={exitAnonymous}>
Выйти из анонимного режима
</button>
)}
</Layout>
);
}

View File

@@ -66,6 +66,9 @@ export function LoginPage() {
<button type="button" className="link-btn" onClick={() => setAskReg(true)}>
Нет аккаунта? Зарегистрироваться
</button>
<button type="button" className="link-btn" onClick={() => navigate("/anonymous")}>
Пройти тест без регистрации
</button>
</form>
{askReg && (

View File

@@ -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<api.Question[]>([]);
const [answers, setAnswers] = useState<Record<string, string>>({});
@@ -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 ?? "Не удалось отправить ответы" });