From d72c14f8bd36b0ee28f78fa324ce250675b24feb Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Thu, 8 Oct 2026 23:13:06 +0200 Subject: [PATCH] Permissions (IAM): L2/L1 moderators and scoped grants (#29) - Roles: reader, mod-l2, mod-l1, admin. Grants: scope (all, poet, book/section with everything in it, one work) x content (poets, books, works, dictionary) x actions (create, edit, delete, arrange); dictionary grants are site-wide. can() in api/src/permissions.ts is the one check. - Admin API and pages: role dropdown on /admin; /admin/user/:id lists a moderator's grants, adds them (target by poet id or page link) and revokes them; demoting a moderator clears their grants; grant and revoke go to the audit log. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- api/src/admin.ts | 9 +- api/src/permissions.test.ts | 74 ++++++++++++++++ api/src/permissions.ts | 131 ++++++++++++++++++++++++++++ api/src/server.ts | 2 + db/schema.sql | 13 +++ web/src/lib/roles.ts | 5 ++ web/src/pages/admin/audit.astro | 6 +- web/src/pages/admin/index.astro | 11 ++- web/src/pages/admin/user/[id].astro | 78 +++++++++++++++++ web/src/styles/global.css | 6 ++ 11 files changed, 326 insertions(+), 11 deletions(-) create mode 100644 api/src/permissions.test.ts create mode 100644 api/src/permissions.ts create mode 100644 web/src/lib/roles.ts create mode 100644 web/src/pages/admin/user/[id].astro diff --git a/README.md b/README.md index a79011e6..29a550b8 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Settings: `DATABASE_URL` (API, default `postgres://divan:divan_local@localhost:5 The import upserts, so re-running it after a divan-data sync applies the changes. -**Accounts and admin.** Readers sign up with an email address and password (no email is sent). The first admin is made on the server: sign up on the site, then `npm run make-admin -- you@example.com` in `api/`. Admins manage users at `/admin` (search, password reset on a reader's request, disable, roles, delete) and see every admin action at `/admin/audit`. +**Accounts and admin.** Readers sign up with an email address and password (no email is sent). The first admin is made on the server: sign up on the site, then `npm run make-admin -- you@example.com` in `api/`. Admins manage users at `/admin` (search, password reset on a reader's request, disable, roles, delete) and see every admin action at `/admin/audit`. Moderators (L2 junior, L1 senior) get scoped permissions from admins: a scope (all poets, a poet, a book with everything in it, or one work), content types (poets, books, works, dictionary) and actions (create, edit, delete, arrange); `can()` in `api/src/permissions.ts` is the one check for moderation. ## Daily content sync (server) diff --git a/api/src/admin.ts b/api/src/admin.ts index c6ceb44c..7b56af4f 100644 --- a/api/src/admin.ts +++ b/api/src/admin.ts @@ -1,10 +1,10 @@ // Admin panel API (admins only). No email server yet, so password resets are done here on a reader's // request: a temporary password is generated, shown to the admin once, and the reader's sessions end. -// Every action is written to audit_log. Moderators and their grants come with IAM (#29). +// Every action is written to audit_log. Moderators' grants: permissions.ts. // GET /api/admin/users?q=&page= users (search by email), newest first // POST /api/admin/users/:id/password -> {password} (temporary, shown once) // POST /api/admin/users/:id/disable {disabled: boolean} -// POST /api/admin/users/:id/role {role: 'reader' | 'admin'} +// POST /api/admin/users/:id/role {role: 'reader' | 'mod-l2' | 'mod-l1' | 'admin'} // POST /api/admin/users/:id/delete // GET /api/admin/audit?page= import { randomInt } from 'node:crypto'; @@ -12,7 +12,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { pool } from './db.ts'; import { hashPassword, sessionUser } from './auth.ts'; -export const ROLES = ['reader', 'admin'] as const; +export const ROLES = ['reader', 'mod-l2', 'mod-l1', 'admin'] as const; // moderators: see permissions.ts const PAGE = 50; // readable temporary password: 12 characters without look-alikes (0/O, 1/l/I) @@ -21,7 +21,7 @@ export function temporaryPassword() { return Array.from({ length: 12 }, () => chars[randomInt(chars.length)]).join(''); } -async function requireAdmin(req: FastifyRequest, reply: FastifyReply) { +export async function requireAdmin(req: FastifyRequest, reply: FastifyReply) { const u = await sessionUser(req); if (!u) return void reply.code(401).send({ error: 'دوبارہ لاگ ان کریں' }); if (u.role !== 'admin') return void reply.code(403).send({ error: 'صرف ایڈمن کے لیے' }); @@ -81,6 +81,7 @@ export function adminRoutes(app: FastifyInstance) { const role = req.body?.role ?? ''; if (!(ROLES as readonly string[]).includes(role)) return reply.code(400).send({ error: 'نامعلوم کردار' }); await pool.query('UPDATE users SET role = $1 WHERE id = $2', [role, u.id]); + if (role === 'reader' || role === 'admin') await pool.query('DELETE FROM grants WHERE user_id = $1', [u.id]); // grants are for moderators await audit(admin, 'role', u, { from: u.role, to: role }); return { ok: true }; }); diff --git a/api/src/permissions.test.ts b/api/src/permissions.test.ts new file mode 100644 index 00000000..36f3f97b --- /dev/null +++ b/api/src/permissions.test.ts @@ -0,0 +1,74 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { authRoutes } from './auth.ts'; +import { adminRoutes } from './admin.ts'; +import { permissionRoutes, can, covers } from './permissions.ts'; +import { pool } from './db.ts'; + +after(() => pool.end()); + +test('scope coverage: all, poet, book (with everything under it), one work', () => { + const at = { poemId: 7, chain: [30, 20, 10], poetId: 1 }; // work 7 in section 30, inside book 20, inside the poet's root 10 + assert.ok(covers({ scope: 'all', scope_id: null }, at)); + assert.ok(covers({ scope: 'poet', scope_id: 1 }, at) && !covers({ scope: 'poet', scope_id: 2 }, at)); + assert.ok(covers({ scope: 'category', scope_id: 20 }, at) && !covers({ scope: 'category', scope_id: 31 }, at)); + assert.ok(covers({ scope: 'poem', scope_id: 7 }, at) && !covers({ scope: 'poem', scope_id: 8 }, at)); +}); + +test('grants through the admin API; can() on real content; demotion clears grants', async () => { + const app = Fastify(); + authRoutes(app); adminRoutes(app); permissionRoutes(app); + const run = Date.now(); + const call = (method: string, url: string, body?: object, token?: string) => + app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `perm-${run}` } }); + const signup = async (email: string) => (await call('POST', '/api/auth/signup', { email, password: 'pass-word-1' })).json(); + const admin = await signup(`perm-admin-${run}@divan.test`), mod = await signup(`perm-mod-${run}@divan.test`); + await pool.query(`UPDATE users SET role = 'admin' WHERE id = $1`, [admin.user.id]); + const grant = (body: object) => call('POST', `/api/admin/users/${mod.user.id}/grants`, body, admin.token); + + // real content: a Ghalib ghazal, a Ghalib work outside the ghazal section, an Iqbal work + const ghazals = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id; + const ghazal = (await pool.query('SELECT id FROM poems WHERE category_id = $1 LIMIT 1', [ghazals])).rows[0].id; + const otherGhalib = (await pool.query('SELECT id FROM poems WHERE poet_id = 266 AND category_id <> $1 LIMIT 1', [ghazals])).rows[0].id; + const iqbal = (await pool.query('SELECT id FROM poems WHERE poet_id = 238 LIMIT 1')).rows[0].id; + + assert.equal((await grant({ scope: 'poet', target: '266', content: ['works'], actions: ['edit'] })).statusCode, 400, 'readers get no grants'); + await call('POST', `/api/admin/users/${mod.user.id}/role`, { role: 'mod-l2' }, admin.token); + const m = { id: mod.user.id, role: 'mod-l2' }; + + assert.equal((await grant({ scope: 'category', target: 'http://127.0.0.1:4200/p266/ghazal', content: ['works'], actions: ['edit', 'arrange'] })).statusCode, 200); + assert.equal(await can(m, 'edit', 'works', { poemId: ghazal }), true, 'a ghazal inside the granted section'); + assert.equal(await can(m, 'arrange', 'works', { categoryId: ghazals }), true); + assert.equal(await can(m, 'edit', 'works', { poemId: otherGhalib }), false, 'outside the section'); + assert.equal(await can(m, 'delete', 'works', { poemId: ghazal }), false, 'action not granted'); + assert.equal(await can(m, 'edit', 'poets', { poetId: 266 }), false, 'content type not granted'); + assert.equal(await can(m, 'edit', 'works', { poemId: iqbal }), false, 'another poet'); + + assert.equal((await grant({ scope: 'poet', target: '/p238', content: ['works', 'books'], actions: ['create', 'edit', 'delete'] })).statusCode, 200); + assert.equal(await can(m, 'delete', 'works', { poemId: iqbal }), true, "anything of Iqbal's"); + assert.equal(await can(m, 'create', 'books', { poetId: 238 }), true); + + assert.equal((await grant({ scope: 'poet', target: '266', content: ['dictionary'], actions: ['edit'] })).statusCode, 400, 'dictionary is site-wide only'); + assert.equal((await grant({ scope: 'all', content: ['dictionary'], actions: ['edit'] })).statusCode, 200); + assert.equal(await can(m, 'edit', 'dictionary'), true); + assert.equal(await can(m, 'delete', 'dictionary'), false); + assert.equal((await grant({ scope: 'poem', target: '/p266/nowhere', content: ['works'], actions: ['edit'] })).statusCode, 400, 'unknown page'); + + assert.equal(await can({ id: 0, role: 'reader' }, 'edit', 'works', { poemId: ghazal }), false); + assert.equal(await can({ id: admin.user.id, role: 'admin' }, 'delete', 'poets', { poetId: 266 }), true); + assert.equal(await can(null, 'edit', 'works', { poemId: ghazal }), false); + assert.equal((await call('GET', `/api/admin/users/${mod.user.id}/grants`, undefined, mod.token)).statusCode, 403, 'moderators cannot manage grants'); + + const list = (await call('GET', `/api/admin/users/${mod.user.id}/grants`, undefined, admin.token)).json().grants; + assert.deepEqual(list.map((g: any) => [g.scope, g.label]), [['category', 'غزل'], ['poet', (await pool.query('SELECT nickname FROM poets WHERE id = 238')).rows[0].nickname], ['all', null]]); + assert.equal((await call('POST', `/api/admin/grants/${list[1].id}/delete`, undefined, admin.token)).statusCode, 200); + assert.equal(await can(m, 'delete', 'works', { poemId: iqbal }), false, 'revoked'); + + await call('POST', `/api/admin/users/${mod.user.id}/role`, { role: 'reader' }, admin.token); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM grants WHERE user_id = $1', [mod.user.id])).rows[0].n, 0, 'demotion clears grants'); + + await pool.query('DELETE FROM users WHERE id = ANY($1)', [[admin.user.id, mod.user.id]]); + await pool.query(`DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1`, [`perm-%-${run}@divan.test`]); + await app.close(); +}); diff --git a/api/src/permissions.ts b/api/src/permissions.ts new file mode 100644 index 00000000..09c4e0d0 --- /dev/null +++ b/api/src/permissions.ts @@ -0,0 +1,131 @@ +// Permissions (IAM) for content moderation (#29). Roles: reader, mod-l2 (junior moderator), mod-l1 +// (senior moderator), admin. Admins grant moderators scoped permissions: +// scope all | poet | category (a book or section, with everything under it) | poem (one work) +// content poets, books, works, dictionary (dictionary grants are site-wide: scope 'all') +// actions create, edit, delete, arrange +// can() is the one check every moderation endpoint uses. Admins can do everything; readers nothing. +// GET /api/admin/users/:id/grants +// POST /api/admin/users/:id/grants {scope, target, content[], actions[]} target: poet id or a page URL +// POST /api/admin/grants/:id/delete +import type { FastifyInstance } from 'fastify'; +import { pool } from './db.ts'; +import { audit, requireAdmin } from './admin.ts'; + +export const MODERATORS = ['mod-l2', 'mod-l1'] as const; +export const SCOPES = ['all', 'poet', 'category', 'poem'] as const; +export const CONTENT = ['poets', 'books', 'works', 'dictionary'] as const; +export const ACTIONS = ['create', 'edit', 'delete', 'arrange'] as const; +export type Action = (typeof ACTIONS)[number]; +export type Content = (typeof CONTENT)[number]; +// what is being acted on: a poet, a book/section, a work, or nothing for site-wide content (dictionary) +export type Target = { poetId?: number; categoryId?: number; poemId?: number }; + +// where a target sits: its poet, its book/section chain (itself and all ancestors), the work itself +async function locate(t: Target) { + let poemId = t.poemId ?? null, categoryId = t.categoryId ?? null, poetId = t.poetId ?? null; + if (poemId) { + const p = (await pool.query('SELECT category_id, poet_id FROM poems WHERE id = $1', [poemId])).rows[0]; + if (!p) return null; + [categoryId, poetId] = [p.category_id, p.poet_id]; + } + const chain: number[] = []; + if (categoryId) { + const { rows } = await pool.query( + `WITH RECURSIVE up AS (SELECT id, parent_id, poet_id FROM categories WHERE id = $1 + UNION ALL SELECT c.id, c.parent_id, c.poet_id FROM categories c JOIN up ON c.id = up.parent_id) + SELECT id, poet_id FROM up`, [categoryId]); + if (!rows.length) return null; + chain.push(...rows.map((r) => r.id)); + poetId ??= rows[0].poet_id; + } + return { poemId, chain, poetId }; +} + +// does a grant's scope cover the target? +export function covers(g: { scope: string; scope_id: number | null }, at: { poemId: number | null; chain: number[]; poetId: number | null }) { + return g.scope === 'all' + || (g.scope === 'poet' && g.scope_id === at.poetId) + || (g.scope === 'category' && at.chain.includes(g.scope_id!)) + || (g.scope === 'poem' && g.scope_id === at.poemId); +} + +export async function can(user: { id: number | string; role: string } | null, action: Action, content: Content, target: Target = {}) { + if (!user) return false; + if (user.role === 'admin') return true; + if (!(MODERATORS as readonly string[]).includes(user.role)) return false; + const { rows: grants } = await pool.query( + 'SELECT scope, scope_id FROM grants WHERE user_id = $1 AND $2 = ANY(actions) AND $3 = ANY(content)', [user.id, action, content]); + if (!grants.length) return false; + const at = await locate(target); + return !!at && grants.some((g) => covers(g, at)); +} + +// a grant's target from the admin form: a poet id, or a page URL (/p266, /p266/ghazal, /p266/ghazal/sh7870) +async function resolveTarget(scope: string, target: string) { + if (scope === 'all') return { id: null, label: 'تمام' }; + const url = '/' + String(target ?? '').trim().replace(/^https?:\/\/[^/]+/, '').replace(/^\/+|\/+$/g, '').replace(/[?#].*$/, ''); + if (scope === 'poet') { + const r = (await pool.query('SELECT id, nickname FROM poets WHERE id = $1 OR url = $2', [Number(target) || 0, url.split('/').slice(0, 2).join('/')])).rows[0]; + return r && { id: r.id, label: r.nickname }; + } + if (scope === 'category') { + const r = (await pool.query('SELECT id, title, url FROM categories WHERE url = $1', [decodeURI(url)])).rows[0]; + return r && { id: r.id, label: r.title }; + } + const r = (await pool.query('SELECT id, title FROM poems WHERE url = $1', [decodeURI(url)])).rows[0]; + return r && { id: r.id, label: r.title }; +} + +const pick = (xs: unknown, allowed: readonly T[]) => + [...new Set((Array.isArray(xs) ? xs : [xs]).filter((x): x is T => allowed.includes(x as T)))]; + +export async function grantsOf(userId: number) { + const { rows } = await pool.query( + `SELECT g.id, g.scope, g.scope_id, g.content, g.actions, g.created_at, + coalesce(p.nickname, c.title, w.title) AS label, coalesce(p.url, c.url, w.url) AS url, + coalesce(c_poet.nickname, w_poet.nickname) AS poet + FROM grants g + LEFT JOIN poets p ON g.scope = 'poet' AND p.id = g.scope_id + LEFT JOIN categories c ON g.scope = 'category' AND c.id = g.scope_id LEFT JOIN poets c_poet ON c_poet.id = c.poet_id + LEFT JOIN poems w ON g.scope = 'poem' AND w.id = g.scope_id LEFT JOIN poets w_poet ON w_poet.id = w.poet_id + WHERE g.user_id = $1 ORDER BY g.created_at`, [userId]); + return rows.map((r) => ({ ...r, id: Number(r.id) })); +} + +export function permissionRoutes(app: FastifyInstance) { + app.get<{ Params: { id: string } }>('/api/admin/users/:id/grants', async (req, reply) => { + if (!(await requireAdmin(req, reply))) return; + const u = (await pool.query('SELECT id, email, role, disabled_at FROM users WHERE id = $1', [Number(req.params.id) || 0])).rows[0]; + if (!u) return reply.code(404).send({ error: 'صارف نہیں ملا' }); + return { user: { ...u, id: Number(u.id) }, grants: await grantsOf(u.id) }; + }); + + app.post<{ Params: { id: string }; Body: { scope?: string; target?: string; content?: unknown; actions?: unknown } }>( + '/api/admin/users/:id/grants', async (req, reply) => { + const admin = await requireAdmin(req, reply); if (!admin) return; + const u = (await pool.query('SELECT id, email, role FROM users WHERE id = $1', [Number(req.params.id) || 0])).rows[0]; + if (!u) return reply.code(404).send({ error: 'صارف نہیں ملا' }); + if (!(MODERATORS as readonly string[]).includes(u.role)) return reply.code(400).send({ error: 'اجازتیں صرف موڈریٹرز کو دی جا سکتی ہیں' }); + const scope = String(req.body?.scope ?? ''); + const content = pick(req.body?.content, CONTENT), actions = pick(req.body?.actions, ACTIONS); + if (!(SCOPES as readonly string[]).includes(scope)) return reply.code(400).send({ error: 'دائرہ منتخب کریں' }); + if (!content.length || !actions.length) return reply.code(400).send({ error: 'کم از کم ایک قسم اور ایک عمل منتخب کریں' }); + if (content.includes('dictionary') && scope !== 'all') return reply.code(400).send({ error: 'لغت کی اجازت صرف "تمام" دائرے میں دی جا سکتی ہے' }); + const t = await resolveTarget(scope, String(req.body?.target ?? '')); + if (!t) return reply.code(400).send({ error: 'شاعر، کتاب یا کلام نہیں ملا۔ صفحے کا لنک دیکھیں۔' }); + const { rows } = await pool.query( + 'INSERT INTO grants (user_id, scope, scope_id, content, actions, granted_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id', + [u.id, scope, t.id, content, actions, admin.id]); + await audit(admin, 'grant', u, { scope, target: t.label, content, actions }); + return { id: Number(rows[0].id) }; + }); + + app.post<{ Params: { id: string } }>('/api/admin/grants/:id/delete', async (req, reply) => { + const admin = await requireAdmin(req, reply); if (!admin) return; + const g = (await pool.query( + 'DELETE FROM grants g USING users u WHERE g.id = $1 AND u.id = g.user_id RETURNING g.scope, g.content, g.actions, u.id, u.email', [Number(req.params.id) || 0])).rows[0]; + if (!g) return reply.code(404).send({ error: 'اجازت نہیں ملی' }); + await audit(admin, 'revoke', g, { scope: g.scope, content: g.content, actions: g.actions }); + return { ok: true }; + }); +} diff --git a/api/src/server.ts b/api/src/server.ts index ea9ff9d5..aa543462 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -12,6 +12,7 @@ import { likePatterns, normalise, terms } from './urdu.ts'; import { lookup, PUNCT } from './dictionary.ts'; import { authRoutes } from './auth.ts'; import { adminRoutes } from './admin.ts'; +import { permissionRoutes } from './permissions.ts'; const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }); const PAGE_SIZE = 20; @@ -139,6 +140,7 @@ app.get<{ Querystring: { w?: string } }>('/api/word', async (req, reply) => { authRoutes(app); adminRoutes(app); +permissionRoutes(app); const port = Number(process.env.PORT ?? 4100); await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' }); diff --git a/db/schema.sql b/db/schema.sql index a216ee6a..34f5a888 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -110,3 +110,16 @@ CREATE TABLE IF NOT EXISTS audit_log ( detail jsonb ); CREATE INDEX IF NOT EXISTS audit_log_at ON audit_log(at DESC); +-- moderators' permissions (api/src/permissions.ts); users.role: reader | mod-l2 | mod-l1 | admin +CREATE TABLE IF NOT EXISTS grants ( + id bigserial PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scope text NOT NULL CHECK (scope IN ('all', 'poet', 'category', 'poem')), + scope_id integer, -- poets.id / categories.id / poems.id; NULL for 'all' + content text[] NOT NULL, -- poets, books, works, dictionary + actions text[] NOT NULL, -- create, edit, delete, arrange + granted_by bigint REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK ((scope = 'all') = (scope_id IS NULL)) +); +CREATE INDEX IF NOT EXISTS grants_user ON grants(user_id); diff --git a/web/src/lib/roles.ts b/web/src/lib/roles.ts new file mode 100644 index 00000000..bfb5ffd0 --- /dev/null +++ b/web/src/lib/roles.ts @@ -0,0 +1,5 @@ +// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts) +export const ROLE: Record = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' }; +export const SCOPE: Record = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' }; +export const CONTENT: Record = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت' }; +export const ACTION: Record = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' }; diff --git a/web/src/pages/admin/audit.astro b/web/src/pages/admin/audit.astro index 0240bda2..0d60edc3 100644 --- a/web/src/pages/admin/audit.astro +++ b/web/src/pages/admin/audit.astro @@ -4,6 +4,7 @@ import Base from '../../layouts/Base.astro'; import AdminNav from '../../components/AdminNav.astro'; import { asUser, COOKIE } from '../../lib/auth'; import { ud } from '../../lib/urdu'; +import { ROLE, CONTENT, ACTION as DO } from '../../lib/roles'; const me = Astro.locals.user; if (!me) return Astro.redirect('/signin?next=/admin/audit'); @@ -13,8 +14,8 @@ const log = (await asUser(Astro.cookies.get(COOKIE)!.value, `/api/admin/audit?pa const pages = Math.ceil(log.total / log.pageSize); const ACTION: Record = { 'password-reset': 'پاس ورڈ ری سیٹ', disable: 'معطل', enable: 'بحال', role: 'کردار', delete: 'حذف', promote: 'ایڈمن (سرور سے)', + grant: 'اجازت دی', revoke: 'اجازت واپس لی', }; -const ROLE: Record = { admin: 'ایڈمن', reader: 'قاری' }; const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' ')); --- @@ -29,7 +30,8 @@ const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T {e.actor_email} {ACTION[e.action] ?? e.action} {e.target_email ?? '—'} - {e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '') : ''} + {e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '') + : e.detail?.content ? `${e.detail.target ?? ''} · ${e.detail.content.map((c: string) => CONTENT[c]).join('، ')} · ${e.detail.actions.map((a: string) => DO[a]).join('، ')}` : ''} ))} diff --git a/web/src/pages/admin/index.astro b/web/src/pages/admin/index.astro index b9047bd1..6b06abdf 100644 --- a/web/src/pages/admin/index.astro +++ b/web/src/pages/admin/index.astro @@ -4,6 +4,7 @@ import Base from '../../layouts/Base.astro'; import AdminNav from '../../components/AdminNav.astro'; import { asUser, COOKIE } from '../../lib/auth'; import { ud } from '../../lib/urdu'; +import { ROLE } from '../../lib/roles'; const me = Astro.locals.user; if (!me) return Astro.redirect('/signin?next=/admin'); @@ -56,7 +57,7 @@ const link = (n: number) => `/admin?q=${encodeURIComponent(q)}&page=${n}`; {list.users.map((u: any) => ( {u.email}{u.id === me.id && (آپ)} - {u.role === 'admin' ? 'ایڈمن' : 'قاری'} + {ROLE[u.role] ?? u.role}{u.role.startsWith('mod-') && <> · اجازتیں} {date(u.created_at)} {date(u.last_sign_in)} {u.disabled_at ? 'معطل' : 'فعال'} @@ -71,10 +72,12 @@ const link = (n: number) => `/admin?q=${encodeURIComponent(q)}&page=${n}`; -
+ - - + +
diff --git a/web/src/pages/admin/user/[id].astro b/web/src/pages/admin/user/[id].astro new file mode 100644 index 00000000..086cf1e4 --- /dev/null +++ b/web/src/pages/admin/user/[id].astro @@ -0,0 +1,78 @@ +--- +// Admin: one moderator's permissions (grants) +import Base from '../../../layouts/Base.astro'; +import AdminNav from '../../../components/AdminNav.astro'; +import { asUser, COOKIE } from '../../../lib/auth'; +import { ROLE, SCOPE, CONTENT, ACTION } from '../../../lib/roles'; + +const me = Astro.locals.user; +if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`); +if (me.role !== 'admin') return new Response('صرف ایڈمن کے لیے', { status: 403 }); +const token = Astro.cookies.get(COOKIE)!.value, id = Number(Astro.params.id) || 0; + +let error = '', done = ''; +if (Astro.request.method === 'POST') { + const f = await Astro.request.formData(); + const r = f.get('act') === 'revoke' + ? await asUser(token, `/api/admin/grants/${Number(f.get('grant'))}/delete`, {}) + : await asUser(token, `/api/admin/users/${id}/grants`, { + scope: f.get('scope'), target: f.get('target'), content: f.getAll('content'), actions: f.getAll('actions'), + }); + r.ok ? (done = f.get('act') === 'revoke' ? 'اجازت واپس لے لی گئی' : 'اجازت دے دی گئی') : (error = r.data.error ?? 'کچھ غلط ہو گیا'); +} +const res = await asUser(token, `/api/admin/users/${id}/grants`); +if (res.status === 404) return new Response('صارف نہیں ملا', { status: 404 }); +const { user, grants } = res.data; +const isMod = user.role.startsWith('mod-'); +--- + +

اجازتیں

+ +

{user.email} · {ROLE[user.role] ?? user.role}

+ {error && } + {done &&

{done}

} + {!isMod &&

اجازتیں صرف موڈریٹرز کو دی جا سکتی ہیں۔ پہلے صارفین میں کردار بدلیں۔

} + + {grants.length > 0 && ( + + + + {grants.map((g: any) => ( + + + + + + + ))} + +
دائرہمواداعمال
{SCOPE[g.scope]}{g.label && <>: {g.url ? {g.label} : g.label}}{g.poet && g.scope !== 'poet' && ({g.poet})}{g.content.map((c: string) => CONTENT[c]).join('، ')}{g.actions.map((a: string) => ACTION[a]).join('، ')} + + + + +
+ )} + + {isMod && ( + + )} + diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 6b68ffcf..134efda4 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -204,3 +204,9 @@ h1 + .muted { text-align: center; margin-top: 0; } .temp-password { max-width: 520px; margin: 10px auto; padding: 10px 16px; border: 1.5px solid var(--gold); border-radius: 12px; background: var(--inner); text-align: center; } .temp-password code { font-size: 1.2rem; letter-spacing: .08em; user-select: all; } @media (max-width: 700px) { .admin-table thead { display: none; } .admin-table tr { display: block; border-bottom: 1px dashed var(--gold-light); padding: 6px 0; } .admin-table td { display: inline-block; border: 0; padding: 2px 6px; } } + +.role-form { display: flex; gap: 4px; align-items: center; } +.role-form select, .grant-form select { font: inherit; font-size: .85rem; padding: 2px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--paper); color: var(--ink); } +.grant-form fieldset { border: 1px dashed var(--gold-light); border-radius: 10px; display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 6px 12px; } +.grant-form legend { font-size: .85rem; padding: 0 6px; } +.grant-form label.check { flex-direction: row; align-items: center; gap: 6px; } -- 2.47.3