diff --git a/backend/scripts/check-route-auth.js b/backend/scripts/check-route-auth.js index a032e98..fd60560 100644 --- a/backend/scripts/check-route-auth.js +++ b/backend/scripts/check-route-auth.js @@ -32,8 +32,9 @@ */ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('fs'); +const path = require('path'); +const registry = require('../src/permissions/registry'); const ROUTES_DIR = path.join(__dirname, '../src/routes'); @@ -42,6 +43,7 @@ const GUARDS = [ 'requireOwnership', 'requireRole', 'requirePermission', + 'perm', // ergonomic alias from auth.js that validates key at registration time 'parentAuth', 'authMiddleware', 'requireAuth', @@ -128,6 +130,33 @@ function main() { console.log(`Route auth check: ${total} :id-routes total, ${unprotected} unprotected (baseline: ${BASELINE})`); + /* ── Permission key validation ───────────────────────────────────────── */ + // Scan all route files for requirePermission('X') and perm('X') calls. + // Verify each key exists in the central registry. + const permKeyRe = /(?:requirePermission|perm)\(\s*['"`]([^'"`]+)['"`]/g; + const unknownPermKeys = []; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + let m; + permKeyRe.lastIndex = 0; + while ((m = permKeyRe.exec(src)) !== null) { + const key = m[1]; + if (!registry.isKnown(key)) { + unknownPermKeys.push({ file: path.basename(file), key }); + } + } + } + if (unknownPermKeys.length > 0) { + console.error('\nFAIL: requirePermission/perm calls with keys not in registry:'); + for (const { file, key } of unknownPermKeys) { + console.error(` ${file}: "${key}"`); + } + console.error('Add missing keys to backend/src/permissions/registry.js'); + process.exit(1); + } else { + console.log('Permission key check: all requirePermission/perm keys are registered.'); + } + if (unprotected > BASELINE) { console.error(`\nFAIL: ${unprotected - BASELINE} new unprotected route(s) added above baseline.`); console.error('Add requireOwnership/requireRole/requirePermission, or mark intentional:'); diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 18e290b..3e35b61 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -451,13 +451,16 @@ function updateFeatures(req, res) { 'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom']; const updates = req.body; const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); - const changed = []; + const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?"); for (const [name, enabled] of Object.entries(updates)) { if (!allowed.includes(name)) continue; - stmt.run(`feature_${name}_enabled`, enabled ? '1' : '0'); - changed.push(`${name}=${enabled ? 'on' : 'off'}`); + const settingKey = `feature_${name}_enabled`; + const oldRow = getOld.get(settingKey); + const oldVal = oldRow ? oldRow.value : null; + const newVal = enabled ? '1' : '0'; + stmt.run(settingKey, newVal); + audit(req, 'feature.update', `feature:${name}`, `${oldVal} -> ${newVal}`); } - if (changed.length) audit(req, 'features.update', null, changed.join(', ')); res.json({ ok: true }); } diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 68c8cf4..7906b26 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -1,177 +1,13 @@ const db = require('../db/db'); +const { audit } = require('../utils/audit'); +const registry = require('../permissions/registry'); -/* ── All known permissions ─────────────────────────────────────────────── */ +/* ── All known permissions — sourced from central registry ────────────── */ +// Only teacher and student entries are exposed to the admin UI. +// free_student shares the same keys as student (handled in auth.js fallback). const ALL_PERMISSIONS = [ - /* ── Teacher ── */ - { - key: 'questions.manage', - role: 'teacher', - label: 'Управление вопросами', - desc: 'Создавать, редактировать и копировать вопросы в банке', - default: 0, - }, - { - key: 'questions.delete', - role: 'teacher', - label: 'Удалять вопросы', - desc: 'Удалять вопросы из банка (требует "Управление вопросами")', - default: 0, - }, - { - key: 'students.invite', - role: 'teacher', - label: 'Регистрировать учеников', - desc: 'Создавать новые аккаунты учеников напрямую из панели', - default: 0, - }, - { - key: 'sessions.reset', - role: 'teacher', - label: 'Сброс попыток', - desc: 'Сбрасывать прохождение теста ученика в своём классе', - default: 1, - }, - { - key: 'results.export', - role: 'teacher', - label: 'Экспорт результатов', - desc: 'Выгружать результаты и оценки класса в CSV', - default: 1, - }, - { - key: 'classes.manage', - role: 'teacher', - label: 'Управление классами', - desc: 'Создавать, редактировать и удалять свои классы', - default: 1, - }, - { - key: 'library.upload', - role: 'teacher', - label: 'Загрузка файлов', - desc: 'Загружать файлы в библиотеку', - default: 1, - }, - { - key: 'library.folders', - role: 'teacher', - label: 'Управление папками', - desc: 'Создавать папки и настраивать доступ к ним', - default: 1, - }, - { - key: 'schedule.manage', - role: 'teacher', - label: 'Дедлайны заданий', - desc: 'Устанавливать дедлайны и временные окна для заданий', - default: 1, - }, - { - key: 'announcements.send', - role: 'teacher', - label: 'Объявления', - desc: 'Публиковать объявления в своих классах', - default: 1, - }, - { - key: 'templates.manage', - role: 'teacher', - label: 'Управление шаблонами', - desc: 'Создавать и использовать шаблоны курсов и уроков', - default: 1, - }, - { - key: 'templates.public', - role: 'teacher', - label: 'Публикация шаблонов', - desc: 'Делать свои шаблоны публичными для всех учителей', - default: 0, - }, - { - key: 'courses.manage', - role: 'teacher', - label: 'Управление курсами', - desc: 'Создавать и редактировать теоретические курсы и уроки', - default: 1, - }, - { - key: 'courses.interactive', - role: 'teacher', - label: 'Интерактивные блоки', - desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)', - default: 1, - }, - { - key: 'shop.manage', - role: 'teacher', - label: 'Управление магазином', - desc: 'Создавать и редактировать товары в магазине наград', - default: 0, - }, - { - key: 'gamification.manage', - role: 'teacher', - label: 'Управление геймификацией', - desc: 'Начислять XP/монеты ученикам, управлять достижениями', - default: 0, - }, - /* ── Student ── */ - { - key: 'tests.free', - role: 'student', - label: 'Свободные тесты', - desc: 'Проходить тесты без задания (по предмету / случайно)', - default: 1, - }, - { - key: 'board.post', - role: 'student', - label: 'Реакции на доске', - desc: 'Ставить реакции на задания на доске', - default: 1, - }, - { - key: 'profile.edit', - role: 'student', - label: 'Редактирование профиля', - desc: 'Изменять своё имя и пароль', - default: 1, - }, - { - key: 'shop.purchase', - role: 'student', - label: 'Покупки в магазине', - desc: 'Покупать предметы в магазине наград за монеты', - default: 1, - }, - { - key: 'gamification.challenges', - role: 'student', - label: 'Испытания недели', - desc: 'Участвовать в еженедельных испытаниях и получать награды', - default: 1, - }, - { - key: 'theory.access', - role: 'student', - label: 'Доступ к теории', - desc: 'Просматривать теоретические курсы и уроки', - default: 1, - }, - { - key: 'simulations.access', - role: 'student', - label: 'Доступ к симуляциям', - desc: 'Открывать лабораторию с физическими, химическими и биологическими симуляциями', - default: 1, - }, - { - key: 'simulations.quiz', - role: 'student', - label: 'Задания в симуляциях', - desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)', - default: 1, - }, + ...registry.byRole('teacher'), + ...registry.byRole('student'), ]; /* ── Seed defaults once per startup ───────────────────────────────────── */ @@ -203,9 +39,16 @@ function setPermission(req, res) { return res.status(400).json({ error: 'Invalid role' }); if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === role)) return res.status(400).json({ error: 'Unknown permission' }); - db.prepare( - 'INSERT OR REPLACE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)' - ).run(role, permission, enabled ? 1 : 0); + db.transaction(() => { + db.prepare( + 'INSERT OR REPLACE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)' + ).run(role, permission, enabled ? 1 : 0); + // Invalidate JWTs for all users of that role so the change takes effect immediately + db.prepare( + 'UPDATE users SET token_version = token_version + 1 WHERE role = ?' + ).run(role); + })(); + audit(req, 'permission.set', `role:${role}/${permission}`, `enabled=${enabled ? 1 : 0}`); res.json({ ok: true }); } @@ -239,19 +82,19 @@ function getMyPermissions(req, res) { /* ── GET /api/permissions/users/:id ──────────────────────────────────── */ function getUserPermissions(req, res) { const uid = Number(req.params.id); - const target = require('../db/db').prepare('SELECT id, role FROM users WHERE id = ?').get(uid); + const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid); if (!target) return res.status(404).json({ error: 'User not found' }); seedDefaults(); // role-level values - const roleRows = require('../db/db').prepare( + const roleRows = db.prepare( 'SELECT permission, enabled FROM role_permissions WHERE role = ?' ).all(target.role); const roleMap = {}; for (const r of roleRows) roleMap[r.permission] = r.enabled === 1; // user-level overrides - const userRows = require('../db/db').prepare( + const userRows = db.prepare( 'SELECT permission, enabled FROM user_permissions WHERE user_id = ?' ).all(uid); const userMap = {}; @@ -274,13 +117,20 @@ function getUserPermissions(req, res) { function setUserPermission(req, res) { const uid = Number(req.params.id); const { permission, enabled } = req.body; - const target = require('../db/db').prepare('SELECT role FROM users WHERE id = ?').get(uid); + const target = db.prepare('SELECT role FROM users WHERE id = ?').get(uid); if (!target) return res.status(404).json({ error: 'User not found' }); if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === target.role)) return res.status(400).json({ error: 'Unknown permission for this role' }); - require('../db/db').prepare( - 'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)' - ).run(uid, permission, enabled ? 1 : 0); + db.transaction(() => { + db.prepare( + 'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)' + ).run(uid, permission, enabled ? 1 : 0); + // Invalidate existing JWT for this user immediately + db.prepare( + 'UPDATE users SET token_version = token_version + 1 WHERE id = ?' + ).run(uid); + })(); + audit(req, 'permission.user_set', `user:${uid}/${permission}`, `enabled=${enabled ? 1 : 0}`); res.json({ ok: true }); } @@ -288,13 +138,19 @@ function setUserPermission(req, res) { function resetUserPermissions(req, res) { const uid = Number(req.params.id); const { permission } = req.body; // optional: reset one key - if (permission) { - require('../db/db').prepare( - 'DELETE FROM user_permissions WHERE user_id = ? AND permission = ?' - ).run(uid, permission); - } else { - require('../db/db').prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid); - } + db.transaction(() => { + if (permission) { + db.prepare( + 'DELETE FROM user_permissions WHERE user_id = ? AND permission = ?' + ).run(uid, permission); + } else { + db.prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid); + } + // Bump token_version so the user's JWT picks up the new effective permissions + // immediately (could be a downgrade if override was =1 and role default is =0). + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + })(); + audit(req, 'permission.user_reset', `user:${uid}`, permission || null); res.json({ ok: true }); } diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 21b0c05..6395ccd 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,47 +1,9 @@ -const jwt = require('jsonwebtoken'); -const db = require('../db/db'); +const jwt = require('jsonwebtoken'); +const db = require('../db/db'); +const registry = require('../permissions/registry'); -/* ── Default values for role_permissions (mirrors permissionsController) ── */ -const PERM_DEFAULTS = { - teacher: { - 'questions.manage': false, - 'questions.delete': false, - 'students.invite': false, - 'sessions.reset': true, - 'results.export': true, - 'classes.manage': true, - 'library.upload': true, - 'library.folders': true, - 'schedule.manage': true, - 'announcements.send': true, - 'templates.manage': true, - 'templates.public': false, - 'courses.manage': true, - 'courses.interactive': true, - 'shop.manage': false, - 'gamification.manage': false, - }, - student: { - 'tests.free': true, - 'board.post': true, - 'profile.edit': true, - 'shop.purchase': true, - 'gamification.challenges': true, - 'theory.access': true, - 'simulations.access': true, - 'simulations.quiz': true, - }, - free_student: { - 'tests.free': true, - 'board.post': true, - 'profile.edit': true, - 'shop.purchase': true, - 'gamification.challenges': true, - 'theory.access': true, - 'simulations.access': true, - 'simulations.quiz': true, - }, -}; +/* ── Default values for role_permissions — sourced from central registry ── */ +const PERM_DEFAULTS = registry.buildDefaultsMap(); function authMiddleware(req, res, next) { const header = req.headers.authorization; @@ -134,6 +96,18 @@ function parentAuth(req, res, next) { /* Alias: requireAuth = authMiddleware */ const requireAuth = authMiddleware; +/** + * perm(key) — ergonomic alias for requirePermission(key). + * Throws at module-load time if `key` is not in the registry, + * so typos are caught at startup rather than at runtime. + */ +function perm(key) { + if (!registry.isKnown(key)) { + throw new Error(`[auth] Unknown permission key: "${key}". Add it to backend/src/permissions/registry.js`); + } + return requirePermission(key); +} + /* optionalAuth: попытаться установить req.user, но не блокировать при отсутствии токена */ function optionalAuth(req, res, next) { const header = req.headers.authorization || ''; @@ -151,4 +125,4 @@ function optionalAuth(req, res, next) { next(); } -module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, parentAuth }; +module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth }; diff --git a/backend/src/middleware/features.js b/backend/src/middleware/features.js new file mode 100644 index 0000000..cc7532d --- /dev/null +++ b/backend/src/middleware/features.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Feature-flag middleware: blocks the request when the named feature is + * globally disabled in app_settings. + * + * Scope (B-lite): GLOBAL only — checks the app_settings.feature__enabled + * row that admin toggles in the admin panel. Per-class disable + * (classes.features JSON) and the free_student role-level overlay + * (app_settings.free_student_features) are NOT checked here — those layers + * remain UI-gated in /api/features. A student bypassing the UI gate via + * direct curl is the documented limitation; can be tightened later by + * extracting the merge logic from server.js → /api/features into a shared + * helper. + * + * Default: missing key = enabled (opt-in disable model). + * + * Response: 404 on disabled feature (intentional — don't leak endpoint shape). + * + * Usage: + * app.use('/api/pet', requireFeature('pet'), petRoutes); + * router.get('/hangman/word', requireFeature('hangman'), authMiddleware, handler); + */ +const db = require('../db/db'); + +const _stmt = db.prepare( + "SELECT value FROM app_settings WHERE key = ?" +); + +function requireFeature(name) { + const settingKey = `feature_${name}_enabled`; + return (req, res, next) => { + const row = _stmt.get(settingKey); + if (row && row.value === '0') { + return res.status(404).json({ error: 'Feature disabled' }); + } + next(); + }; +} + +module.exports = { requireFeature }; diff --git a/backend/src/permissions/registry.js b/backend/src/permissions/registry.js new file mode 100644 index 0000000..8ccde6f --- /dev/null +++ b/backend/src/permissions/registry.js @@ -0,0 +1,199 @@ +'use strict'; + +/** + * Single source of truth for all granular permissions. + * Keys here MUST match what controllers/routes use in requirePermission(...). + * + * Shape: + * : { + * role: 'teacher' | 'student', // primary role for admin UI grouping + * roles: string[], // all roles this key applies to + * default: 0 | 1, // PERM_DEFAULTS value + * label: 'Short label for admin UI', + * desc: 'Russian description for admin UI' + * } + * + * DISCREPANCY NOTE (2026-05-17): + * auth.js PERM_DEFAULTS includes a 'free_student' role with the same 8 + * permission keys as 'student' (identical key strings, identical defaults). + * permissionsController.js ALL_PERMISSIONS does NOT list free_student entries + * (seedDefaults/admin UI only handles teacher and student). + * Resolution: each student-role key carries roles:['student','free_student'] + * so that requirePermission() fallback works correctly for free_student users. + * The admin UI behaviour (filtering to teacher/student only) is preserved — + * permissionsController reads from registry.byRole('teacher') and + * registry.byRole('student'). + */ + +const PERMISSIONS = { + /* ── Teacher ─────────────────────────────────────────────────────────── */ + 'questions.manage': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Управление вопросами', + desc: 'Создавать, редактировать и копировать вопросы в банке', + requireConfirmOff: true, + }, + 'questions.delete': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Удалять вопросы', + desc: 'Удалять вопросы из банка (требует "Управление вопросами")', + }, + 'students.invite': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Регистрировать учеников', + desc: 'Создавать новые аккаунты учеников напрямую из панели', + }, + 'sessions.reset': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Сброс попыток', + desc: 'Сбрасывать прохождение теста ученика в своём классе', + requireConfirmOff: true, + }, + 'results.export': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Экспорт результатов', + desc: 'Выгружать результаты и оценки класса в CSV', + }, + 'classes.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Управление классами', + desc: 'Создавать, редактировать и удалять свои классы', + requireConfirmOff: true, + }, + 'library.upload': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Загрузка файлов', + desc: 'Загружать файлы в библиотеку', + requireConfirmOff: true, + }, + 'library.folders': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Управление папками', + desc: 'Создавать папки и настраивать доступ к ним', + }, + 'schedule.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Дедлайны заданий', + desc: 'Устанавливать дедлайны и временные окна для заданий', + }, + 'announcements.send': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Объявления', + desc: 'Публиковать объявления в своих классах', + }, + 'templates.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Управление шаблонами', + desc: 'Создавать и использовать шаблоны курсов и уроков', + }, + 'templates.public': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Публикация шаблонов', + desc: 'Делать свои шаблоны публичными для всех учителей', + }, + 'courses.manage': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Управление курсами', + desc: 'Создавать и редактировать теоретические курсы и уроки', + requireConfirmOff: true, + }, + 'courses.interactive': { + role: 'teacher', roles: ['teacher'], default: 1, + label: 'Интерактивные блоки', + desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)', + }, + 'shop.manage': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Управление магазином', + desc: 'Создавать и редактировать товары в магазине наград', + }, + 'gamification.manage': { + role: 'teacher', roles: ['teacher'], default: 0, + label: 'Управление геймификацией', + desc: 'Начислять XP/монеты ученикам, управлять достижениями', + }, + + /* ── Student (also applies to free_student — same keys, same defaults) ── */ + 'tests.free': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Свободные тесты', + desc: 'Проходить тесты без задания (по предмету / случайно)', + }, + 'board.post': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Реакции на доске', + desc: 'Ставить реакции на задания на доске', + }, + 'profile.edit': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Редактирование профиля', + desc: 'Изменять своё имя и пароль', + }, + 'shop.purchase': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Покупки в магазине', + desc: 'Покупать предметы в магазине наград за монеты', + }, + 'gamification.challenges': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Испытания недели', + desc: 'Участвовать в еженедельных испытаниях и получать награды', + }, + 'theory.access': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Доступ к теории', + desc: 'Просматривать теоретические курсы и уроки', + requireConfirmOff: true, + }, + 'simulations.access': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Доступ к симуляциям', + desc: 'Открывать лабораторию с физическими, химическими и биологическими симуляциями', + requireConfirmOff: true, + }, + 'simulations.quiz': { + role: 'student', roles: ['student', 'free_student'], default: 1, + label: 'Задания в симуляциях', + desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)', + }, +}; + +/** + * Check whether a given permission key exists in the registry. + * Used by perm() helper in auth.js to fail early on typos. + */ +function isKnown(key) { + return Object.prototype.hasOwnProperty.call(PERMISSIONS, key); +} + +/** Return all registered permission keys. */ +function listKeys() { + return Object.keys(PERMISSIONS); +} + +/** + * Return all entries whose primary role matches. + * Returns objects shaped like ALL_PERMISSIONS entries (key, role, default, label, desc). + */ +function byRole(role) { + return Object.entries(PERMISSIONS) + .filter(([, v]) => v.role === role) + .map(([key, v]) => ({ key, role: v.role, default: v.default, label: v.label, desc: v.desc, requireConfirmOff: !!v.requireConfirmOff })); +} + +/** + * Build a PERM_DEFAULTS-style lookup: { role: { key: bool } } + * Used by auth.js requirePermission fallback. + */ +function buildDefaultsMap() { + const map = {}; + for (const [key, v] of Object.entries(PERMISSIONS)) { + for (const r of v.roles) { + if (!map[r]) map[r] = {}; + map[r][key] = v.default === 1; + } + } + return map; +} + +module.exports = { PERMISSIONS, isKnown, listKeys, byRole, buildDefaultsMap }; diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js index 7bae763..b22564d 100644 --- a/backend/src/routes/games.js +++ b/backend/src/routes/games.js @@ -1,10 +1,14 @@ const router = require('express').Router(); const { authMiddleware } = require('../middleware/auth'); +const { requireFeature } = require('../middleware/features'); const c = require('../controllers/gamesController'); -router.get('/hangman/word', authMiddleware, c.hangmanWord); -router.post('/hangman/complete', authMiddleware, c.hangmanComplete); -router.get('/crossword/generate', authMiddleware, c.crosswordGenerate); -router.post('/crossword/complete', authMiddleware, c.crosswordComplete); +const hangman = requireFeature('hangman'); +const crossword = requireFeature('crossword'); + +router.get('/hangman/word', hangman, authMiddleware, c.hangmanWord); +router.post('/hangman/complete', hangman, authMiddleware, c.hangmanComplete); +router.get('/crossword/generate', crossword, authMiddleware, c.crosswordGenerate); +router.post('/crossword/complete', crossword, authMiddleware, c.crosswordComplete); module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 816fb58..325f79c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -130,6 +130,7 @@ app.use(express.json({ limit: '1mb' })); /* ── Global API rate limit ── */ const rateLimit = require('./middleware/rateLimit'); +const { requireFeature } = require('./middleware/features'); // Classroom real-time endpoints (cursor, stroke-preview) fire ~10/s per user — higher limit app.use('/api/classroom', rateLimit({ windowMs: 60_000, max: 6000, message: 'Слишком много запросов' })); app.use('/api', rateLimit({ windowMs: 60_000, max: 600, message: 'Слишком много запросов, подождите минуту' })); @@ -154,7 +155,7 @@ app.use('/api/shop', shopRoutes); app.use('/api/templates', templateRoutes); app.use('/api/bookmarks', bookmarkRoutes); app.use('/api/search', searchRoutes); -app.use('/api/flashcards', flashcardRoutes); +app.use('/api/flashcards', requireFeature('flashcards'), flashcardRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/preferences', require('./routes/preferences')); app.use('/api/avatar', require('./routes/avatar')); @@ -163,11 +164,11 @@ app.use('/api/live', liveRoutes); app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom app.use('/api/classroom', classroomRoutes); app.use('/api/games', gamesRoutes); -app.use('/api/knowledge-map', knowledgeMapRoutes); -app.use('/api/pet', petRoutes); -app.use('/api/collection', collectionRoutes); -app.use('/api/red-book', redBookRoutes); -app.use('/api/biochem', require('./routes/biochem')); +app.use('/api/knowledge-map', requireFeature('knowledge_map'), knowledgeMapRoutes); +app.use('/api/pet', requireFeature('pet'), petRoutes); +app.use('/api/collection', requireFeature('collection'), collectionRoutes); +app.use('/api/red-book', requireFeature('red_book'), redBookRoutes); +app.use('/api/biochem', requireFeature('biochem'), require('./routes/biochem')); app.use('/api/parent', parentRoutes); app.use('/api/exam9', exam9Routes); app.use('/api/textbooks', textbookRoutes); diff --git a/frontend/admin.html b/frontend/admin.html index 4c55c25..9c7a73f 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -257,6 +257,12 @@ .perm-toggle input:checked ~ .perm-track { background: var(--green, #06d6a0); } .perm-toggle input:checked ~ .perm-thumb { transform: translateX(20px); } .perm-toggle input:focus-visible ~ .perm-track { outline: 2px solid var(--violet); } + /* dot shown when a role-level perm differs from its registry default */ + .perm-modified-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--amber, #FFB347); flex-shrink: 0; + vertical-align: middle; margin-left: 6px; + } /* toolbar */ .t-toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 24px; } @@ -1219,6 +1225,12 @@

Настройте, что могут делать учителя и ученики. Администраторы имеют все права всегда.

+
+ +
+
Учитель @@ -1559,10 +1571,11 @@
Права пользователя
-

Индивидуальные настройки переопределяют права роли для этого учителя.

+

Индивидуальные настройки переопределяют права роли для этого пользователя.

- diff --git a/frontend/js/admin/sections/permissions.js b/frontend/js/admin/sections/permissions.js index 83c8f04..35a888f 100644 --- a/frontend/js/admin/sections/permissions.js +++ b/frontend/js/admin/sections/permissions.js @@ -23,10 +23,14 @@ const defs = definitions.filter(d => d.role === role); container.innerHTML = defs.map(def => { const enabled = permissions[role]?.[def.key] ?? def.default; + const isModified = (enabled ? 1 : 0) !== def.default; + const modDot = isModified + ? `` + : ''; return `
-
${esc(def.label)}
+
${esc(def.label)}${modDot}
${esc(def.desc)}