feat(permissions): B8 — временные права (expires_at) с авто-снятием

Миграция 053: user_permissions.expires_at (NULL = бессрочно). Резолвер isEnabled
+ /me + /users/:id игнорируют просроченные оверрайды (наследуют роль); seedDefaults
чистит просроченные строки. setUserPermission принимает days → выдаёт право на
срок (datetime('now','+N days')). API отдаёт expiresAt. Клиент: setUserPermission(...,days).
В модалке прав пользователя — бейдж «до ДАТА» + кнопка «врем.» (выдать на N дней).
Тест: срок хранится/отдаётся, просроченное игнорируется и вычищается. Backend pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 14:43:06 +03:00
parent 8b495f1508
commit a250d15f9a
6 changed files with 83 additions and 14 deletions
@@ -19,6 +19,9 @@ function seedDefaults() {
for (const p of ALL_PERMISSIONS) upsert.run(p.role, p.key, p.default);
});
run();
// Чистим просроченные временные оверрайды (B8). Резолвер их и так игнорирует —
// здесь убираем «мусор» из таблицы.
try { db.prepare("DELETE FROM user_permissions WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')").run(); } catch (_e) { /* колонки может не быть на старом инстансе */ }
}
/* ── GET /api/permissions ─────────────────────────────────────────────── */
@@ -64,7 +67,7 @@ function getMyPermissions(req, res) {
for (const r of roleRows) roleMap[r.permission] = r.enabled === 1;
const userRows = db.prepare(
'SELECT permission, enabled FROM user_permissions WHERE user_id = ?'
"SELECT permission, enabled FROM user_permissions WHERE user_id = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).all(uid);
const userMap = {};
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
@@ -93,12 +96,12 @@ function getUserPermissions(req, res) {
const roleMap = {};
for (const r of roleRows) roleMap[r.permission] = r.enabled === 1;
// user-level overrides
// user-level overrides (просроченные временные не учитываем)
const userRows = db.prepare(
'SELECT permission, enabled FROM user_permissions WHERE user_id = ?'
"SELECT permission, enabled, expires_at FROM user_permissions WHERE user_id = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).all(uid);
const userMap = {};
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
const userMap = {}, userExp = {};
for (const r of userRows) { userMap[r.permission] = r.enabled === 1; if (r.expires_at) userExp[r.permission] = r.expires_at; }
const defs = ALL_PERMISSIONS.filter(p => p.role === target.role);
const base = {};
@@ -110,6 +113,7 @@ function getUserPermissions(req, res) {
requires: d.requires || [],
roleVal: roleMap[d.key] ?? d.default, // effective role-level value
userVal: userMap[d.key], // undefined = no override
expiresAt: userExp[d.key] || null, // срок временного оверрайда (UTC) или null
effective: base[d.key] && (d.requires || []).every(r => !!base[r]),
}));
@@ -124,17 +128,25 @@ function setUserPermission(req, res) {
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' });
const days = Number(req.body.days);
const hasExp = Number.isInteger(days) && days > 0; // временный оверрайд на N дней
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
if (hasExp) {
db.prepare(
"INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled, expires_at) VALUES (?, ?, ?, datetime('now', ?))"
).run(uid, permission, enabled ? 1 : 0, '+' + days + ' days');
} else {
db.prepare(
'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled, expires_at) VALUES (?, ?, ?, NULL)'
).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 });
audit(req, 'permission.user_set', `user:${uid}/${permission}`, `enabled=${enabled ? 1 : 0}${hasExp ? ` exp=+${days}d` : ''}`);
res.json({ ok: true, expires_in_days: hasExp ? days : null });
}
/* ── DELETE /api/permissions/users/:id/reset (single or all) ─────────── */
@@ -0,0 +1,6 @@
-- 053_user_permission_expiry.sql
-- Временные права (B8): личный оверрайд может иметь срок действия. По истечении
-- срока он перестаёт учитываться (резолвер фильтрует по expires_at) и периодически
-- удаляется. NULL = бессрочно (как и было). Время в UTC ('YYYY-MM-DD HH:MM:SS').
ALTER TABLE user_permissions ADD COLUMN expires_at TEXT;
+2 -1
View File
@@ -44,8 +44,9 @@ function requireRole(...roles) {
/* ── Разрешено ли ОДНО право: user override → role override → дефолт реестра ── */
function isEnabled(uid, role, key) {
// Просроченный временный оверрайд (expires_at в прошлом) игнорируем — наследуем роль.
const userRow = db.prepare(
'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?'
"SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(uid, key);
if (userRow !== undefined) return userRow.enabled === 1;
const roleRow = db.prepare(
+26
View File
@@ -274,4 +274,30 @@ describe('Permissions', () => {
const badP = await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'nope' }, adminToken);
assert.equal(badP.status, 400);
});
// ── B8: временные права (expires_at) ───────────────────────────────────────
it('B8: временный оверрайд хранит срок, отдаётся, просроченный игнорируется и чистится', async () => {
const r = await inject('POST', `/api/permissions/users/${studentUser.userId}`,
{ permission: 'shop.purchase', enabled: false, days: 7 }, adminToken);
assert.equal(r.status, 200);
assert.equal(r.body.expires_in_days, 7);
const row = db.prepare('SELECT enabled, expires_at FROM user_permissions WHERE user_id=? AND permission=?')
.get(studentUser.userId, 'shop.purchase');
assert.ok(row && row.enabled === 0 && row.expires_at, 'оверрайд с expires_at сохранён');
const view = await inject('GET', `/api/permissions/users/${studentUser.userId}`, null, adminToken);
const sp = view.body.permissions.find(p => p.key === 'shop.purchase');
assert.equal(sp.userVal, false, 'активный временный оверрайд виден');
assert.ok(sp.expiresAt, 'expiresAt отдаётся в API');
// имитируем просрочку
db.prepare("UPDATE user_permissions SET expires_at = datetime('now','-1 day') WHERE user_id=? AND permission=?")
.run(studentUser.userId, 'shop.purchase');
const view2 = await inject('GET', `/api/permissions/users/${studentUser.userId}`, null, adminToken);
const sp2 = view2.body.permissions.find(p => p.key === 'shop.purchase');
assert.equal(sp2.userVal, undefined, 'просроченный оверрайд не учитывается (наследует роль)');
const left = db.prepare("SELECT COUNT(*) n FROM user_permissions WHERE user_id=? AND permission=?")
.get(studentUser.userId, 'shop.purchase').n;
assert.equal(left, 0, 'просроченная строка вычищена seedDefaults');
});
});
+25 -1
View File
@@ -364,6 +364,12 @@
const badge = hasOverride
? `<span style="font-size:11px;padding:2px 5px;border-radius:var(--r-pill);background:rgba(155,93,229,0.12);color:var(--violet);font-weight:700">Индивидуально</span>`
: `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(136,152,170,0.12);color:var(--text-3);font-weight:700">По роли</span>`;
const expBadge = (hasOverride && p.expiresAt)
? `<span title="Временный оверрайд истекает (UTC)" style="font-size:11px;padding:2px 6px;border-radius:var(--r-pill);background:rgba(245,158,11,0.14);color:#b45309;font-weight:700">до ${esc(p.expiresAt.slice(0, 10))}</span>`
: '';
const tempBtn = `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:600"
onmouseover="this.style.color='var(--violet)'" onmouseout="this.style.color='var(--text-3)'"
onclick="doSetUserPermTemp('${esc(p.key)}')" title="Выдать право на срок">врем.</button>`;
const resetBtn = hasOverride
? `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:700;transition:color .2s"
onmouseover="this.style.color='var(--danger)'" onmouseout="this.style.color='var(--text-3)'"
@@ -372,9 +378,11 @@
return `
<div class="perm-card${checked ? ' enabled' : ''}" id="up-perm-card-${p.key.replace('.','_')}">
<div class="perm-info">
<div style="display:flex;align-items:center;gap:7px">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap">
<span class="perm-label">${esc(p.label)}</span>
${badge}
${expBadge}
${tempBtn}
${resetBtn}
</div>
<div class="perm-desc">${esc(p.desc)}</div>
@@ -408,6 +416,21 @@
}
}
async function doSetUserPermTemp(key) {
const uid = getActiveUid();
if (!uid) return;
const raw = window.prompt('Выдать право временно. На сколько дней?', '7');
if (raw === null) return;
const days = parseInt(raw, 10);
if (!Number.isInteger(days) || days <= 0) { LS.toast('Введите число дней > 0', 'error'); return; }
try {
await LS.setUserPermission(uid, key, true, days);
_upPermsData = await LS.getUserPermissions(uid);
renderUserPerms();
LS.toast(`Право выдано на ${days} дн.`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function doResetOneUserPerm(key) {
const uid = getActiveUid();
if (!uid) return;
@@ -445,6 +468,7 @@
window.closeUserPermsModal = closeUserPermsModal;
window.openUserPermsModal = openUserPermsModal;
window.doSetUserPerm = doSetUserPerm;
window.doSetUserPermTemp = doSetUserPermTemp;
window.doResetOneUserPerm = doResetOneUserPerm;
window.doResetAllUserPerms = doResetAllUserPerms;
// Phase 5 quick actions
+1 -1
View File
@@ -1301,7 +1301,7 @@ async function permissionsPresets() { return req('GET',
async function applyClassPreset(classId, preset) { return req('POST', `/permissions/class/${classId}/preset`, { preset }); }
async function setPermission(role, permission, enabled) { return req('POST', '/permissions', { role, permission, enabled }); }
async function getUserPermissions(uid) { return req('GET', `/permissions/users/${uid}`); }
async function setUserPermission(uid, permission, enabled) { return req('POST', `/permissions/users/${uid}`, { permission, enabled }); }
async function setUserPermission(uid, permission, enabled, days) { return req('POST', `/permissions/users/${uid}`, days ? { permission, enabled, days } : { permission, enabled }); }
async function resetUserPermissions(uid, permission) { return req('DELETE', `/permissions/users/${uid}/reset`, permission ? { permission } : undefined); }
/* ── content access (учебники / экзамены: открыть-закрыть классам/ученикам) ── */