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:
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (учебники / экзамены: открыть-закрыть классам/ученикам) ── */
|
||||
|
||||
Reference in New Issue
Block a user