From 471171b77c9e1329b7d311c34188cddd77cd3018 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 12:33:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=20=D0=BA=20=D1=83=D1=87=D0=B5=D0=B1=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=20=D0=B8=20=D1=8D=D0=BA=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=D0=BC=20=D0=BF=D0=BE=20=D0=BA=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=B0=D0=BC/=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B8=D0=B7=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD-=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модель allowlist (закрыто по умолчанию), правило ученика важнее класса. Управляют админ (все) и учителя (свои классы/ученики). - миграция 040: таблица content_access + непрерывный переход (всем существующим классам открыт текущий контент) - сервис contentAccess: резолвинг доступа, главы наследуют хаб - API /api/access (catalog/targets/rules) для admin+teacher - гейты: каталог учебников, router.param slug/examKey, фильтр tracks - клиентские редиректы на /403 (textbook-tracker, exam-prep boot) - раздел админки «Доступ к учебникам»: классы + ученики (tri-state) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/db/migrations/040_content_access.sql | 44 +++++ backend/src/routes/access.js | 168 ++++++++++++++++ backend/src/routes/exam-prep.js | 15 +- backend/src/routes/textbooks.js | 13 +- backend/src/server.js | 2 + backend/src/services/contentAccess.js | 95 +++++++++ frontend/admin.html | 28 +++ frontend/js/admin/admin.js | 1 + frontend/js/admin/sections/access.js | 184 ++++++++++++++++++ frontend/js/exam-prep/common.js | 1 + frontend/js/textbook-tracker.js | 5 +- js/api.js | 12 ++ 12 files changed, 564 insertions(+), 4 deletions(-) create mode 100644 backend/src/db/migrations/040_content_access.sql create mode 100644 backend/src/routes/access.js create mode 100644 backend/src/services/contentAccess.js create mode 100644 frontend/js/admin/sections/access.js diff --git a/backend/src/db/migrations/040_content_access.sql b/backend/src/db/migrations/040_content_access.sql new file mode 100644 index 0000000..c540255 --- /dev/null +++ b/backend/src/db/migrations/040_content_access.sql @@ -0,0 +1,44 @@ +-- 040_content_access.sql +-- Per-class / per-student access control for textbooks and exam modules. +-- +-- Model (chosen 2026-05-30): ALLOWLIST — content is hidden by default and must +-- be explicitly opened for a class or a student. A student-level rule always +-- overrides the class-level rule (точечные исключения). allow = 1 → открыт, +-- allow = 0 → закрыт (используется только как индивидуальное исключение). +-- +-- content_ref: +-- • content_type='textbook' → top-level textbook slug (parent_slug IS NULL). +-- Главы (parent_slug != NULL) наследуют доступ родителя. +-- • content_type='exam' → exam_tracks.exam_key (например 'math9'). + +CREATE TABLE IF NOT EXISTS content_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_type TEXT NOT NULL CHECK (content_type IN ('textbook','exam')), + content_ref TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('class','student')), + target_id INTEGER NOT NULL, -- class_id (scope=class) или user_id (scope=student) + allow INTEGER NOT NULL DEFAULT 1 CHECK (allow IN (0,1)), + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (content_type, content_ref, scope, target_id) +); + +CREATE INDEX IF NOT EXISTS idx_content_access_lookup ON content_access (content_type, content_ref); +CREATE INDEX IF NOT EXISTS idx_content_access_target ON content_access (content_type, scope, target_id); + +-- ── Непрерывный переход ─────────────────────────────────────────────────── +-- До этой миграции всё было открыто всем. Чтобы переход на allowlist не отнял +-- доступ задним числом, выдаём каждому существующему классу доступ ко всем +-- активным учебникам верхнего уровня и ко всем включённым экзамен-трекам. +-- Новый контент, добавленный позже, по умолчанию закрыт — его нужно открыть +-- явно из админ-панели. + +INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow) +SELECT 'textbook', t.slug, 'class', c.id, 1 + FROM textbooks t CROSS JOIN classes c + WHERE t.is_active = 1 AND t.parent_slug IS NULL; + +INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow) +SELECT 'exam', e.exam_key, 'class', c.id, 1 + FROM exam_tracks e CROSS JOIN classes c + WHERE e.enabled = 1; diff --git a/backend/src/routes/access.js b/backend/src/routes/access.js new file mode 100644 index 0000000..88863e0 --- /dev/null +++ b/backend/src/routes/access.js @@ -0,0 +1,168 @@ +'use strict'; +/* /api/access — управление доступом к учебникам и экзамен-модулям. + * + * Доступно админам (все классы/ученики) и учителям (только свои классы и + * ученики своих классов / привязанные ученики). Модель — allowlist, + * правило ученика важнее правила класса (см. services/contentAccess.js). */ +const router = require('express').Router(); +const db = require('../db/db'); +const { authMiddleware, requireRole } = require('../middleware/auth'); + +router.use(authMiddleware); +router.use(requireRole('admin', 'teacher')); + +const isAdmin = (req) => req.user.role === 'admin'; + +/* ── Каталог контента, которым можно управлять ─────────────────────────── */ +/* GET /api/access/catalog → { textbooks:[...], exams:[...] } */ +router.get('/catalog', (_req, res) => { + const textbooks = db.prepare(` + SELECT slug, title, subject, grade, color + FROM textbooks + WHERE is_active = 1 AND parent_slug IS NULL + ORDER BY sort_order, subject, grade + `).all(); + const exams = db.prepare(` + SELECT exam_key, title, subject_slug, grade + FROM exam_tracks + WHERE enabled = 1 + ORDER BY sort_order, exam_key + `).all(); + res.json({ textbooks, exams }); +}); + +/* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */ +/* GET /api/access/targets → { classes:[{id,name,students:[...]}], looseStudents:[...] } */ +router.get('/targets', (req, res) => { + const admin = isAdmin(req); + + const classes = admin + ? db.prepare(`SELECT c.id, c.name, u.name AS teacher_name + FROM classes c LEFT JOIN users u ON u.id = c.teacher_id + ORDER BY c.name`).all() + : db.prepare(`SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name`) + .all(req.user.id); + + const membersStmt = db.prepare(` + SELECT u.id, u.name, u.email + FROM class_members cm JOIN users u ON u.id = cm.user_id + WHERE cm.class_id = ? + ORDER BY u.name + `); + const classList = classes.map(c => ({ ...c, students: membersStmt.all(c.id) })); + + /* «Отдельные ученики» — без класса (admin) / привязанные не в моих классах (teacher). */ + let looseStudents; + if (admin) { + looseStudents = db.prepare(` + SELECT id, name, email FROM users + WHERE role IN ('student','free_student') + AND id NOT IN (SELECT user_id FROM class_members) + ORDER BY name LIMIT 500 + `).all(); + } else { + looseStudents = db.prepare(` + SELECT u.id, u.name, u.email + FROM teacher_students ts JOIN users u ON u.id = ts.student_id + WHERE ts.teacher_id = ? + AND u.id NOT IN ( + SELECT cm.user_id FROM class_members cm + JOIN classes c ON c.id = cm.class_id + WHERE c.teacher_id = ?) + ORDER BY u.name + `).all(req.user.id, req.user.id); + } + + res.json({ classes: classList, looseStudents }); +}); + +/* ── Текущие правила для одного контента ───────────────────────────────── */ +/* GET /api/access/rules?content_type=&content_ref= + → { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */ +router.get('/rules', (req, res) => { + const { content_type, content_ref } = req.query; + if (!['textbook', 'exam'].includes(content_type) || !content_ref) { + return res.status(400).json({ error: 'content_type и content_ref обязательны' }); + } + const rows = db.prepare(` + SELECT scope, target_id, allow FROM content_access + WHERE content_type = ? AND content_ref = ? + `).all(content_type, content_ref); + + const classRules = {}, studentRules = {}; + for (const r of rows) { + if (r.scope === 'class') classRules[r.target_id] = r.allow; + else studentRules[r.target_id] = r.allow; + } + res.json({ classRules, studentRules }); +}); + +/* ── Проверка прав учителя на конкретную цель ──────────────────────────── */ +function teacherOwnsClass(teacherId, classId) { + return !!db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, teacherId); +} +function teacherCanManageStudent(teacherId, studentId) { + const inClass = db.prepare(` + SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id + WHERE c.teacher_id = ? AND cm.user_id = ? LIMIT 1`).get(teacherId, studentId); + if (inClass) return true; + return !!db.prepare('SELECT 1 FROM teacher_students WHERE teacher_id = ? AND student_id = ?') + .get(teacherId, studentId); +} + +/* ── Установка / снятие правила ────────────────────────────────────────── */ +/* POST /api/access/rules + body: { content_type, content_ref, scope, target_id, allow } + allow: 1 (открыть) | 0 (закрыть-исключение) | null/'inherit' (удалить правило) */ +router.post('/rules', (req, res) => { + const { content_type, content_ref, scope, target_id } = req.body || {}; + let { allow } = req.body || {}; + + if (!['textbook', 'exam'].includes(content_type)) { + return res.status(400).json({ error: 'неверный content_type' }); + } + if (!['class', 'student'].includes(scope)) { + return res.status(400).json({ error: 'неверный scope' }); + } + const tid = Number(target_id); + if (!Number.isInteger(tid) || tid <= 0) { + return res.status(400).json({ error: 'неверный target_id' }); + } + + /* Валидация существования контента. */ + if (content_type === 'textbook') { + const ok = db.prepare('SELECT 1 FROM textbooks WHERE slug = ? AND parent_slug IS NULL').get(content_ref); + if (!ok) return res.status(404).json({ error: 'учебник не найден' }); + } else { + const ok = db.prepare('SELECT 1 FROM exam_tracks WHERE exam_key = ?').get(content_ref); + if (!ok) return res.status(404).json({ error: 'экзамен-трек не найден' }); + } + + /* Скоупинг учителя. */ + if (!isAdmin(req)) { + const allowed = scope === 'class' + ? teacherOwnsClass(req.user.id, tid) + : teacherCanManageStudent(req.user.id, tid); + if (!allowed) return res.status(403).json({ error: 'Нет прав на эту цель' }); + } + + /* allow === null / 'inherit' / undefined → удалить правило (наследование). */ + if (allow === null || allow === undefined || allow === 'inherit') { + db.prepare(`DELETE FROM content_access + WHERE content_type = ? AND content_ref = ? AND scope = ? AND target_id = ?`) + .run(content_type, content_ref, scope, tid); + return res.json({ ok: true, allow: null }); + } + + allow = (allow === 1 || allow === true || allow === '1') ? 1 : 0; + db.prepare(` + INSERT INTO content_access (content_type, content_ref, scope, target_id, allow, created_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (content_type, content_ref, scope, target_id) + DO UPDATE SET allow = excluded.allow, created_by = excluded.created_by, created_at = datetime('now') + `).run(content_type, content_ref, scope, tid, allow, req.user.id); + + res.json({ ok: true, allow }); +}); + +module.exports = router; diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 792d4ce..fc484c1 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -2,9 +2,19 @@ const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware } = require('../middleware/auth'); +const access = require('../services/contentAccess'); router.use(authMiddleware); +/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist. + Админ/учитель проходят всегда; ученик — только при наличии правила. */ +router.param('examKey', (req, res, next, examKey) => { + if (!access.canAccessExam(req.user, examKey)) { + return res.status(403).json({ error: 'Нет доступа к этому экзамен-модулю' }); + } + next(); +}); + /* ── Statements (prepared once) ────────────────────────────────── */ const SQL = { listTracks: db.prepare(` @@ -399,8 +409,9 @@ const SQL = { /* ── GET /api/exam-prep/tracks ── Public list of enabled exam tracks (for a future landing page). */ -router.get('/tracks', (_req, res) => { - const tracks = SQL.listTracks.all(); +router.get('/tracks', (req, res) => { + const tracks = SQL.listTracks.all() + .filter(t => access.canAccessExam(req.user, t.exam_key)); res.json({ tracks }); }); diff --git a/backend/src/routes/textbooks.js b/backend/src/routes/textbooks.js index 3ca22ca..376930e 100644 --- a/backend/src/routes/textbooks.js +++ b/backend/src/routes/textbooks.js @@ -3,9 +3,19 @@ const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware, requireRole } = require('../middleware/auth'); const { emit } = require('../sse'); +const access = require('../services/contentAccess'); router.use(authMiddleware); +/* Гейт доступа: любой маршрут с :slug проверяется по allowlist. + Админ/учитель проходят всегда; ученик — только при наличии правила. */ +router.param('slug', (req, res, next, slug) => { + if (!access.canAccessTextbook(req.user, slug)) { + return res.status(403).json({ error: 'Нет доступа к этому учебнику' }); + } + next(); +}); + /* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,...]; empty → [1..fallback] */ function parseTextbookParas(spec, fallback) { if (!spec || !spec.trim()) return Array.from({ length: fallback || 0 }, (_, i) => i + 1); @@ -118,7 +128,8 @@ router.get('/', (req, res) => { }; }); - res.json({ textbooks }); + /* Allowlist: ученику показываем только открытые учебники. */ + res.json({ textbooks: access.filterTextbooks(req.user, textbooks) }); }); /* GET /api/textbooks/bookmarks/all — all my bookmarks across textbooks */ diff --git a/backend/src/server.js b/backend/src/server.js index 2e19cd3..5a7e988 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -53,6 +53,7 @@ const parentRoutes = require('./routes/parent'); const exam9Routes = require('./routes/exam9'); const examPrepRoutes = require('./routes/exam-prep'); const textbookRoutes = require('./routes/textbooks'); +const accessRoutes = require('./routes/access'); const teacherStudentsRoutes = require('./routes/teacherStudents'); const { requestId, errorHandler } = require('./middleware/errorHandler'); @@ -174,6 +175,7 @@ app.use('/api/parent', parentRoutes); app.use('/api/exam9', exam9Routes); app.use('/api/exam-prep', examPrepRoutes); app.use('/api/textbooks', textbookRoutes); +app.use('/api/access', accessRoutes); app.use('/api/teacher-students', teacherStudentsRoutes); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ diff --git a/backend/src/services/contentAccess.js b/backend/src/services/contentAccess.js new file mode 100644 index 0000000..c8a4e28 --- /dev/null +++ b/backend/src/services/contentAccess.js @@ -0,0 +1,95 @@ +'use strict'; +/* contentAccess — резолвинг доступа к учебникам / экзамен-модулям. + * + * Модель: ALLOWLIST. По умолчанию закрыто. Правило ученика важнее правила + * класса. См. миграцию 040_content_access.sql. + * + * canAccessTextbook(user, slug) → bool + * canAccessExam(user, examKey) → bool + * filterTextbooks(user, rows) → отфильтрованный список (rows[*].slug) + * allowedRefs(userId, type) → Set доступных пользователю + */ +const db = require('../db/db'); + +const PRIVILEGED = new Set(['admin', 'teacher']); + +const _parentOf = db.prepare('SELECT parent_slug FROM textbooks WHERE slug = ?'); +/* Глава наследует доступ родителя-хаба; верхнеуровневый учебник — ключ = own slug. */ +function textbookAccessKey(slug) { + const row = _parentOf.get(slug); + return row && row.parent_slug ? row.parent_slug : slug; +} + +const _studentRule = db.prepare(` + SELECT allow FROM content_access + WHERE content_type = ? AND content_ref = ? AND scope = 'student' AND target_id = ? +`); +const _classRule = db.prepare(` + SELECT MAX(allow) AS any_allow, COUNT(*) AS n + FROM content_access + WHERE content_type = ? AND content_ref = ? AND scope = 'class' + AND target_id IN (SELECT class_id FROM class_members WHERE user_id = ?) +`); + +function resolve(userId, type, ref) { + const s = _studentRule.get(type, ref, userId); + if (s) return s.allow === 1; // правило ученика побеждает + const c = _classRule.get(type, ref, userId); + if (c && c.n > 0) return c.any_allow === 1; // открыт хотя бы одним классом + return false; // allowlist — по умолчанию закрыто +} + +function canAccess(user, type, ref) { + if (!user) return false; + if (PRIVILEGED.has(user.role)) return true; // админ/учитель видят весь контент + return resolve(user.id, type, ref); +} + +function canAccessTextbook(user, slug) { + return canAccess(user, 'textbook', textbookAccessKey(slug)); +} +function canAccessExam(user, examKey) { + return canAccess(user, 'exam', examKey); +} + +/* Множество доступных пользователю content_ref для типа (bulk, для каталога). */ +const _allStudentRules = db.prepare(` + SELECT content_ref, allow FROM content_access + WHERE content_type = ? AND scope = 'student' AND target_id = ? +`); +const _allClassRules = db.prepare(` + SELECT content_ref, MAX(allow) AS any_allow + FROM content_access + WHERE content_type = ? AND scope = 'class' + AND target_id IN (SELECT class_id FROM class_members WHERE user_id = ?) + GROUP BY content_ref +`); +function allowedRefs(userId, type) { + const out = new Set(); + for (const r of _allClassRules.all(type, userId)) { + if (r.any_allow === 1) out.add(r.content_ref); + } + for (const r of _allStudentRules.all(type, userId)) { + if (r.allow === 1) out.add(r.content_ref); // ученик-разрешение добавляет + else out.delete(r.content_ref); // ученик-запрет снимает + } + return out; +} + +/* Фильтрует список учебников верхнего уровня (каждый row имеет .slug). */ +function filterTextbooks(user, rows) { + if (!user) return []; + if (PRIVILEGED.has(user.role)) return rows; + const allow = allowedRefs(user.id, 'textbook'); + return rows.filter(r => allow.has(r.slug)); +} + +module.exports = { + PRIVILEGED, + textbookAccessKey, + canAccess, + canAccessTextbook, + canAccessExam, + allowedRefs, + filterTextbooks, +}; diff --git a/frontend/admin.html b/frontend/admin.html index 9c7a73f..8d9141d 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -956,6 +956,9 @@ + @@ -1509,6 +1512,30 @@
+ +
+
Доступ к учебникам и экзаменам
+

+ По умолчанию доступ закрыт. Откройте учебник или экзамен-модуль нужным классам. + Внутри класса можно сделать точечное исключение для отдельного ученика — + индивидуальное правило важнее правила класса. +

+
+
+
Учебники
+
+
Экзамены
+
+
+
+
+ Выберите учебник или экзамен слева, чтобы настроить доступ. +
+ +
+
+
+
Рассылка уведомлений
@@ -2009,6 +2036,7 @@ +
diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index 4877b50..71637f3 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -67,6 +67,7 @@ sims: 'sims', games: 'games', sublog: 'sublog', + access: 'access', }; /* Phase 6: deep entity pages. When a route has a first param (#users/123), diff --git a/frontend/js/admin/sections/access.js b/frontend/js/admin/sections/access.js new file mode 100644 index 0000000..23f5b9a --- /dev/null +++ b/frontend/js/admin/sections/access.js @@ -0,0 +1,184 @@ +'use strict'; +/* admin → access section — открыть/закрыть доступ к учебникам и экзаменам + * для классов и отдельных учеников. Модель allowlist: по умолчанию закрыто, + * правило ученика важнее правила класса. */ +(function () { + 'use strict'; + let inited = false; + let _catalog = null; // { textbooks:[], exams:[] } + let _targets = null; // { classes:[{id,name,students:[]}], looseStudents:[] } + let _sel = null; // { type:'textbook'|'exam', ref, title } + let _rules = { classRules: {}, studentRules: {} }; + const _open = new Set(); // class ids развёрнутых строк + + const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s)); + + async function load() { + try { + [_catalog, _targets] = await Promise.all([LS.accessCatalog(), LS.accessTargets()]); + renderList(); + } catch (e) { + document.getElementById('acc-textbooks').innerHTML = + `

Ошибка загрузки: ${esc(e.message)}

`; + } + } + + function itemBtn(type, ref, title, sub) { + const active = _sel && _sel.type === type && _sel.ref === ref; + return ``; + } + + function renderList() { + const tb = document.getElementById('acc-textbooks'); + const ex = document.getElementById('acc-exams'); + tb.innerHTML = (_catalog.textbooks || []) + .map(t => itemBtn('textbook', t.slug, t.title, t.grade ? t.grade + ' кл.' : '')).join('') + || '

Нет учебников

'; + ex.innerHTML = (_catalog.exams || []) + .map(e => itemBtn('exam', e.exam_key, e.title, e.grade ? e.grade + ' кл.' : '')).join('') + || '

Нет экзаменов

'; + } + + async function select(type, ref) { + const src = type === 'textbook' ? _catalog.textbooks : _catalog.exams; + const keyName = type === 'textbook' ? 'slug' : 'exam_key'; + const item = (src || []).find(x => x[keyName] === ref); + _sel = { type, ref, title: item ? item.title : ref }; + renderList(); + document.getElementById('acc-detail-empty').style.display = 'none'; + const det = document.getElementById('acc-detail'); + det.style.display = ''; + det.innerHTML = '

Загрузка…

'; + try { + _rules = await LS.accessRules(type, ref); + renderDetail(); + } catch (e) { + det.innerHTML = `

Ошибка: ${esc(e.message)}

`; + } + } + + /* tri-state кнопки для ученика внутри класса */ + function studentTri(uid) { + const v = _rules.studentRules[uid]; // 1 | 0 | undefined + const state = v === 1 ? 'open' : v === 0 ? 'closed' : 'inherit'; + const btn = (val, label, on) => + ``; + return ` + ${btn('null', 'Наследовать', state === 'inherit')} + ${btn(1, 'Открыт', state === 'open')} + ${btn(0, 'Закрыт', state === 'closed')} + `; + } + + function classRow(c) { + const openToClass = _rules.classRules[c.id] === 1; + const expanded = _open.has(c.id); + const students = c.students || []; + const studentsHtml = expanded ? ` +
+ ${students.length ? students.map(s => ` +
+ ${esc(s.name || s.email)} + ${studentTri(s.id)} +
`).join('') + : '

В классе нет учеников

'} +
` : ''; + return ` +
+
+ + +
+ ${studentsHtml} +
`; + } + + function looseRow(s) { + const open = _rules.studentRules[s.id] === 1; + return ` +
+ ${esc(s.name || s.email)} ${esc(s.email)} + +
`; + } + + function renderDetail() { + const det = document.getElementById('acc-detail'); + const classes = _targets.classes || []; + const loose = _targets.looseStudents || []; + det.innerHTML = ` +
+
${esc(_sel.title)}
+ ${_sel.type === 'exam' ? 'Экзамен' : 'Учебник'} +
+
+ ${classes.length ? classes.map(classRow).join('') + : '

Нет классов.

'} +
+ ${loose.length ? ` +
+
Отдельные ученики (без класса)
+ ${loose.map(looseRow).join('')} +
` : ''} + `; + } + + /* ── handlers (optimistic update) ── */ + async function setClass(classId, checked) { + const allow = checked ? 1 : null; + try { + await LS.accessSetRule(_sel.type, _sel.ref, 'class', classId, allow); + if (allow === 1) _rules.classRules[classId] = 1; + else delete _rules.classRules[classId]; + renderDetail(); + LS.toast(checked ? 'Открыт классу' : 'Закрыт для класса', 'success'); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderDetail(); } + } + + async function setStudent(uid, allow) { + // allow: 1 | 0 | null (строка 'null' приходит из tri-кнопок) + if (allow === 'null') allow = null; + try { + await LS.accessSetRule(_sel.type, _sel.ref, 'student', uid, allow); + if (allow === 1) _rules.studentRules[uid] = 1; + else if (allow === 0) _rules.studentRules[uid] = 0; + else delete _rules.studentRules[uid]; + renderDetail(); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderDetail(); } + } + + function toggleExpand(classId) { + if (_open.has(classId)) _open.delete(classId); else _open.add(classId); + renderDetail(); + } + + window.accSelect = select; + window.accSetClass = setClass; + window.accSetStudent = setStudent; + window.accToggleExpand = toggleExpand; + + window.AdminSections = window.AdminSections || {}; + window.AdminSections.access = { + init: async () => { if (inited) return; inited = true; await load(); }, + reload: load, + }; +})(); diff --git a/frontend/js/exam-prep/common.js b/frontend/js/exam-prep/common.js index e079bfd..cab0237 100644 --- a/frontend/js/exam-prep/common.js +++ b/frontend/js/exam-prep/common.js @@ -64,6 +64,7 @@ try { info = await LS.api(`/api/exam-prep/${examKey}/info`); } catch (e) { + if (e && e.status === 403) { window.location.replace('/403'); return { examKey, view, info: null }; } console.warn('[exam-prep] info failed', e); } diff --git a/frontend/js/textbook-tracker.js b/frontend/js/textbook-tracker.js index a9990fe..6334f80 100644 --- a/frontend/js/textbook-tracker.js +++ b/frontend/js/textbook-tracker.js @@ -63,7 +63,10 @@ fetch('/api/textbooks/' + slug, { headers: { 'Authorization': 'Bearer ' + LS.getToken() }, }) - .then(r => r.ok ? r.json() : null) + .then(r => { + if (r.status === 403) { window.location.replace('/403'); return null; } + return r.ok ? r.json() : null; + }) .then(d => { if (!d || !d.progress) return; const serverRead = new Set(d.progress.read || []); diff --git a/js/api.js b/js/api.js index 67ca924..dc179b7 100644 --- a/js/api.js +++ b/js/api.js @@ -1025,6 +1025,7 @@ window.LS = { getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList, submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl, getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, + accessCatalog, accessTargets, accessRules, accessSetRule, getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate, getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate, getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark, @@ -1288,6 +1289,17 @@ async function getUserPermissions(uid) { return req('GET', async function setUserPermission(uid, permission, enabled) { return req('POST', `/permissions/users/${uid}`, { permission, enabled }); } async function resetUserPermissions(uid, permission) { return req('DELETE', `/permissions/users/${uid}/reset`, permission ? { permission } : undefined); } +/* ── content access (учебники / экзамены: открыть-закрыть классам/ученикам) ── */ +async function accessCatalog() { return req('GET', '/access/catalog'); } +async function accessTargets() { return req('GET', '/access/targets'); } +async function accessRules(content_type, content_ref) { + const p = new URLSearchParams({ content_type, content_ref }); + return req('GET', `/access/rules?${p}`); +} +async function accessSetRule(content_type, content_ref, scope, target_id, allow) { + return req('POST', '/access/rules', { content_type, content_ref, scope, target_id, allow }); +} + /* ── notifications ───────────────────────────────────────────────────────── */ async function getNotifications() { return req('GET', '/notifications'); } async function markNotifRead(id) { return req('PATCH',`/notifications/${id}/read`); }