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 <noreply@anthropic.com>
This commit is contained in:
parent
48b106857d
commit
d72c14f8bd
@ -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)
|
||||
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
74
api/src/permissions.test.ts
Normal file
74
api/src/permissions.test.ts
Normal file
@ -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();
|
||||
});
|
||||
131
api/src/permissions.ts
Normal file
131
api/src/permissions.ts
Normal file
@ -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 = <T extends string>(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 };
|
||||
});
|
||||
}
|
||||
@ -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' });
|
||||
|
||||
@ -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);
|
||||
|
||||
5
web/src/lib/roles.ts
Normal file
5
web/src/lib/roles.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts)
|
||||
export const ROLE: Record<string, string> = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
|
||||
export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
|
||||
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت' };
|
||||
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };
|
||||
@ -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<string, string> = {
|
||||
'password-reset': 'پاس ورڈ ری سیٹ', disable: 'معطل', enable: 'بحال', role: 'کردار', delete: 'حذف', promote: 'ایڈمن (سرور سے)',
|
||||
grant: 'اجازت دی', revoke: 'اجازت واپس لی',
|
||||
};
|
||||
const ROLE: Record<string, string> = { admin: 'ایڈمن', reader: 'قاری' };
|
||||
const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' '));
|
||||
---
|
||||
<Base title="ایڈمن: ریکارڈ">
|
||||
@ -29,7 +30,8 @@ const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T
|
||||
<td><bdi dir="ltr">{e.actor_email}</bdi></td>
|
||||
<td>{ACTION[e.action] ?? e.action}</td>
|
||||
<td><bdi dir="ltr">{e.target_email ?? '—'}</bdi></td>
|
||||
<td>{e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '') : ''}</td>
|
||||
<td>{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('، ')}` : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -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) => (
|
||||
<tr class={u.disabled_at ? 'disabled' : ''}>
|
||||
<td><bdi dir="ltr">{u.email}</bdi>{u.id === me.id && <small> (آپ)</small>}</td>
|
||||
<td>{u.role === 'admin' ? 'ایڈمن' : 'قاری'}</td>
|
||||
<td>{ROLE[u.role] ?? u.role}{u.role.startsWith('mod-') && <> · <a href={`/admin/user/${u.id}`}>اجازتیں</a></>}</td>
|
||||
<td>{date(u.created_at)}</td>
|
||||
<td>{date(u.last_sign_in)}</td>
|
||||
<td>{u.disabled_at ? 'معطل' : 'فعال'}</td>
|
||||
@ -71,10 +72,12 @@ const link = (n: number) => `/admin?q=${encodeURIComponent(q)}&page=${n}`;
|
||||
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
|
||||
<button name="act" value={u.disabled_at ? 'enable' : 'disable'}>{u.disabled_at ? 'بحال کریں' : 'معطل کریں'}</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<form method="post" class="role-form">
|
||||
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
|
||||
<input type="hidden" name="role" value={u.role === 'admin' ? 'reader' : 'admin'} />
|
||||
<button name="act" value="role">{u.role === 'admin' ? 'قاری بنائیں' : 'ایڈمن بنائیں'}</button>
|
||||
<select name="role" aria-label="کردار">
|
||||
{Object.entries(ROLE).map(([k, v]) => <option value={k} selected={k === u.role}>{v}</option>)}
|
||||
</select>
|
||||
<button name="act" value="role">کردار بدلیں</button>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('یہ اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔ جاری رکھیں؟')">
|
||||
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
|
||||
|
||||
78
web/src/pages/admin/user/[id].astro
Normal file
78
web/src/pages/admin/user/[id].astro
Normal file
@ -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-');
|
||||
---
|
||||
<Base title="ایڈمن: اجازتیں">
|
||||
<h1>اجازتیں</h1>
|
||||
<AdminNav current="users" />
|
||||
<p class="center"><bdi dir="ltr">{user.email}</bdi> · {ROLE[user.role] ?? user.role}</p>
|
||||
{error && <p class="form-error" role="alert">{error}</p>}
|
||||
{done && <p class="form-done" role="status">{done}</p>}
|
||||
{!isMod && <p class="muted center">اجازتیں صرف موڈریٹرز کو دی جا سکتی ہیں۔ پہلے <a href="/admin">صارفین</a> میں کردار بدلیں۔</p>}
|
||||
|
||||
{grants.length > 0 && (
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>دائرہ</th><th>مواد</th><th>اعمال</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{grants.map((g: any) => (
|
||||
<tr>
|
||||
<td>{SCOPE[g.scope]}{g.label && <>: {g.url ? <a href={g.url}>{g.label}</a> : g.label}</>}{g.poet && g.scope !== 'poet' && <small class="muted"> ({g.poet})</small>}</td>
|
||||
<td>{g.content.map((c: string) => CONTENT[c]).join('، ')}</td>
|
||||
<td>{g.actions.map((a: string) => ACTION[a]).join('، ')}</td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('یہ اجازت واپس لیں؟')">
|
||||
<input type="hidden" name="grant" value={g.id} />
|
||||
<button name="act" value="revoke" class="danger">واپس لیں</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{isMod && (
|
||||
<form method="post" class="account-form grant-form">
|
||||
<h2>نئی اجازت</h2>
|
||||
<label>دائرہ
|
||||
<select name="scope" required>
|
||||
{Object.entries(SCOPE).map(([k, v]) => <option value={k}>{v}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>شاعر، کتاب یا کلام کا لنک (دائرہ "تمام" کے لیے خالی)
|
||||
<input name="target" dir="ltr" placeholder="/p266 /p266/ghazal /p266/ghazal/sh7870" />
|
||||
</label>
|
||||
<fieldset><legend>مواد</legend>
|
||||
{Object.entries(CONTENT).map(([k, v]) => <label class="check"><input type="checkbox" name="content" value={k} /> {v}</label>)}
|
||||
</fieldset>
|
||||
<fieldset><legend>اعمال</legend>
|
||||
{Object.entries(ACTION).map(([k, v]) => <label class="check"><input type="checkbox" name="actions" value={k} /> {v}</label>)}
|
||||
</fieldset>
|
||||
<p class="muted note">لغت کی اجازت صرف "تمام" دائرے میں دی جا سکتی ہے۔</p>
|
||||
<button name="act" value="grant">اجازت دیں</button>
|
||||
</form>
|
||||
)}
|
||||
</Base>
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user