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 { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { AuthProvider } from "./auth/AuthContext";
|
import { AuthProvider } from "./auth/AuthContext";
|
||||||
import { ThemeProvider } from "./theme/ThemeContext";
|
import { ThemeProvider } from "./theme/ThemeContext";
|
||||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
import { ProtectedRoute, AccountRoute } from "./components/ProtectedRoute";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { RegisterPage } from "./pages/RegisterPage";
|
import { RegisterPage } from "./pages/RegisterPage";
|
||||||
|
import { AnonymousEntryPage } from "./pages/AnonymousEntryPage";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
import { TestListPage } from "./pages/TestListPage";
|
import { TestListPage } from "./pages/TestListPage";
|
||||||
import { PollPage } from "./pages/PollPage";
|
import { PollPage } from "./pages/PollPage";
|
||||||
@@ -11,10 +12,16 @@ import { MyTestsPage } from "./pages/MyTestsPage";
|
|||||||
import { ResponsePage } from "./pages/ResponsePage";
|
import { ResponsePage } from "./pages/ResponsePage";
|
||||||
import { ProfilePage } from "./pages/ProfilePage";
|
import { ProfilePage } from "./pages/ProfilePage";
|
||||||
|
|
||||||
|
// Доступно и по токену, и анонимно (школа+класс без аккаунта).
|
||||||
function protect(el: React.ReactNode) {
|
function protect(el: React.ReactNode) {
|
||||||
return <ProtectedRoute>{el}</ProtectedRoute>;
|
return <ProtectedRoute>{el}</ProtectedRoute>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Требует настоящий аккаунт — история и профиль анонимусам недоступны.
|
||||||
|
function account(el: React.ReactNode) {
|
||||||
|
return <AccountRoute>{el}</AccountRoute>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
@@ -23,12 +30,13 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/anonymous" element={<AnonymousEntryPage />} />
|
||||||
<Route path="/" element={protect(<HomePage />)} />
|
<Route path="/" element={protect(<HomePage />)} />
|
||||||
<Route path="/tests" element={protect(<TestListPage />)} />
|
<Route path="/tests" element={protect(<TestListPage />)} />
|
||||||
<Route path="/poll/:pollId" element={protect(<PollPage />)} />
|
<Route path="/poll/:pollId" element={protect(<PollPage />)} />
|
||||||
<Route path="/my-tests" element={protect(<MyTestsPage />)} />
|
<Route path="/my-tests" element={account(<MyTestsPage />)} />
|
||||||
<Route path="/response/:responseId" element={protect(<ResponsePage />)} />
|
<Route path="/response/:responseId" element={account(<ResponsePage />)} />
|
||||||
<Route path="/profile" element={protect(<ProfilePage />)} />
|
<Route path="/profile" element={account(<ProfilePage />)} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from "
|
|||||||
import * as api from "../api/client";
|
import * as api from "../api/client";
|
||||||
|
|
||||||
const TOKEN_KEY = "auth_token";
|
const TOKEN_KEY = "auth_token";
|
||||||
|
const ANON_KEY = "anon_identity";
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,15 +12,38 @@ export interface AuthUser {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Анонимная сессия: школьник выбрал школу+класс, но не регистрировался —
|
||||||
|
// аккаунта и токена нет, только org/group для снимка в Response.
|
||||||
|
export interface AnonIdentity {
|
||||||
|
orgId: string;
|
||||||
|
groupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthCtx {
|
interface AuthCtx {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
|
anon: AnonIdentity | null;
|
||||||
ready: boolean; // завершена ли первичная проверка токена
|
ready: boolean; // завершена ли первичная проверка токена
|
||||||
login: (username: string, password: string) => Promise<string | null>;
|
login: (username: string, password: string) => Promise<string | null>;
|
||||||
|
loginAnonymous: (orgId: string, groupId: string) => void;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
setUserFromMe: (me: api.Me) => 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);
|
const Ctx = createContext<AuthCtx | null>(null);
|
||||||
|
|
||||||
function meToUser(me: api.Me): AuthUser {
|
function meToUser(me: api.Me): AuthUser {
|
||||||
@@ -35,6 +59,7 @@ function meToUser(me: api.Me): AuthUser {
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [anon, setAnon] = useState<AnonIdentity | null>(() => readAnon());
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
// При старте: если есть токен — проверяем его через /users/me
|
// При старте: если есть токен — проверяем его через /users/me
|
||||||
@@ -65,6 +90,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const login = async (username: string, password: string): Promise<string | null> => {
|
const login = async (username: string, password: string): Promise<string | null> => {
|
||||||
const { data, error } = await api.login(username, password);
|
const { data, error } = await api.login(username, password);
|
||||||
if (error || !data) return error ?? "Ошибка входа";
|
if (error || !data) return error ?? "Ошибка входа";
|
||||||
|
localStorage.removeItem(ANON_KEY);
|
||||||
|
setAnon(null);
|
||||||
localStorage.setItem(TOKEN_KEY, data);
|
localStorage.setItem(TOKEN_KEY, data);
|
||||||
setToken(data);
|
setToken(data);
|
||||||
const me = await api.getMe(data);
|
const me = await api.getMe(data);
|
||||||
@@ -72,17 +99,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return null;
|
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 () => {
|
const logout = async () => {
|
||||||
if (token) await api.logout(token);
|
if (token) await api.logout(token);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(ANON_KEY);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setAnon(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setUserFromMe = (me: api.Me) => setUser(meToUser(me));
|
const setUserFromMe = (me: api.Me) => setUser(meToUser(me));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider value={{ token, user, ready, login, logout, setUserFromMe }}>
|
<Ctx.Provider value={{ token, user, anon, ready, login, loginAnonymous, logout, setUserFromMe }}>
|
||||||
{children}
|
{children}
|
||||||
</Ctx.Provider>
|
</Ctx.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,26 @@ import type { ReactNode } from "react";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { Spinner } from "./ui";
|
import { Spinner } from "./ui";
|
||||||
|
|
||||||
// Пускает дальше только при наличии токена. Пока идёт первичная проверка — спиннер.
|
// Пускает дальше при наличии токена ИЛИ анонимной идентичности (школа+класс,
|
||||||
|
// без аккаунта). Используется для страниц, которые школьник может пройти
|
||||||
|
// анонимно: главная, список тестов, прохождение теста.
|
||||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
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();
|
const { token, ready } = useAuth();
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { Layout } from "../components/Layout";
|
import { Layout } from "../components/Layout";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { token, anon, logout } = useAuth();
|
||||||
|
const isAnonymous = !token && !!anon;
|
||||||
|
|
||||||
|
const exitAnonymous = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Добро пожаловать в ЦОПП!">
|
<Layout title="Добро пожаловать в ЦОПП!" showProfile={!!token}>
|
||||||
<div className="home-grid">
|
<div className="home-grid">
|
||||||
<div className="home-tile" onClick={() => navigate("/tests")}>
|
<div className="home-tile" onClick={() => navigate("/tests")}>
|
||||||
<img src="/icons/clipboard-question_16542596.svg" alt="" />
|
<img src="/icons/clipboard-question_16542596.svg" alt="" />
|
||||||
<span>Все тесты</span>
|
<span>Все тесты</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* «Мои тесты» требует аккаунта — у анонимной сессии нет истории */}
|
||||||
|
{!isAnonymous && (
|
||||||
<div className="home-tile" onClick={() => navigate("/my-tests")}>
|
<div className="home-tile" onClick={() => navigate("/my-tests")}>
|
||||||
<img src="/icons/leaderboard-trophy_14227576.svg" alt="" />
|
<img src="/icons/leaderboard-trophy_14227576.svg" alt="" />
|
||||||
<span>Мои тесты</span>
|
<span>Мои тесты</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isAnonymous && (
|
||||||
|
<button type="button" className="link-btn" onClick={exitAnonymous}>
|
||||||
|
Выйти из анонимного режима
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export function LoginPage() {
|
|||||||
<button type="button" className="link-btn" onClick={() => setAskReg(true)}>
|
<button type="button" className="link-btn" onClick={() => setAskReg(true)}>
|
||||||
Нет аккаунта? Зарегистрироваться
|
Нет аккаунта? Зарегистрироваться
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="link-btn" onClick={() => navigate("/anonymous")}>
|
||||||
|
Пройти тест без регистрации
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{askReg && (
|
{askReg && (
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type Phase =
|
|||||||
export function PollPage() {
|
export function PollPage() {
|
||||||
const { pollId = "" } = useParams();
|
const { pollId = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user, anon } = useAuth();
|
||||||
|
|
||||||
const [questions, setQuestions] = useState<api.Question[]>([]);
|
const [questions, setQuestions] = useState<api.Question[]>([]);
|
||||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||||
@@ -73,8 +73,8 @@ export function PollPage() {
|
|||||||
}));
|
}));
|
||||||
const { data: responseId, error } = await api.submitResponse(pollId, payload, {
|
const { data: responseId, error } = await api.submitResponse(pollId, payload, {
|
||||||
user_id: user?.id,
|
user_id: user?.id,
|
||||||
group_id: user?.groupId || null,
|
group_id: user?.groupId || anon?.groupId || null,
|
||||||
organization_id: user?.orgId || null,
|
organization_id: user?.orgId || anon?.orgId || null,
|
||||||
});
|
});
|
||||||
if (error || !responseId) {
|
if (error || !responseId) {
|
||||||
setPhase({ kind: "error", message: error ?? "Не удалось отправить ответы" });
|
setPhase({ kind: "error", message: error ?? "Не удалось отправить ответы" });
|
||||||
|
|||||||
Reference in New Issue
Block a user