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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
75
web-react/src/pages/AnonymousEntryPage.tsx
Normal file
75
web-react/src/pages/AnonymousEntryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
{/* «Мои тесты» требует аккаунта — у анонимной сессии нет истории */}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 ?? "Не удалось отправить ответы" });
|
||||
|
||||
Reference in New Issue
Block a user