Files
api-copp/web-react/src/pages/AnonymousEntryPage.tsx
jze9 94299d4af6 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>
2026-07-16 11:56:08 +05:00

76 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}