From acf77674959adfd63c9a167ee0a07d427544401c Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Thu, 8 Oct 2026 23:08:19 +0200 Subject: [PATCH] Admin panel: users, password reset on request, disable, roles, delete, audit log (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API (api/src/admin.ts, admins only): user list and search; password reset generates a temporary password shown once and ends the user's sessions; disable/enable (ends sessions, blocks sign-in); roles reader/admin; delete; an admin cannot disable, demote or delete themself. Every action is written to audit_log (kept when users are deleted). - First admin from the server: npm run make-admin -- (after signing up). - Site: /admin (users) and /admin/audit; 'ایڈمن' link in the header for admins. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + api/package.json | 3 +- api/src/admin-cli.ts | 18 +++++ api/src/admin.test.ts | 68 +++++++++++++++++++ api/src/admin.ts | 105 ++++++++++++++++++++++++++++++ api/src/auth.ts | 10 ++- api/src/server.ts | 3 + db/schema.sql | 14 ++++ web/src/components/AdminNav.astro | 7 ++ web/src/layouts/Base.astro | 1 + web/src/lib/auth.ts | 12 +++- web/src/pages/admin/audit.astro | 44 +++++++++++++ web/src/pages/admin/index.astro | 97 +++++++++++++++++++++++++++ web/src/styles/global.css | 18 +++++ 14 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 api/src/admin-cli.ts create mode 100644 api/src/admin.test.ts create mode 100644 api/src/admin.ts create mode 100644 web/src/components/AdminNav.astro create mode 100644 web/src/pages/admin/audit.astro create mode 100644 web/src/pages/admin/index.astro diff --git a/README.md b/README.md index 6d5eaecf..a79011e6 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ 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`. + ## Daily content sync (server) `deploy/sync.sh` keeps a server current: it updates a divan-data checkout, fetches new and edited works from Wikisource (incremental, about a minute), rebuilds the export and upserts it into PostgreSQL. The site shows new content immediately. Runs are locked so they never overlap. diff --git a/api/package.json b/api/package.json index c5206c0f..c5de0b38 100644 --- a/api/package.json +++ b/api/package.json @@ -10,7 +10,8 @@ "start": "node src/server.ts", "import": "node src/import.ts", "test": "node --test src/*.test.ts", - "dict-sync": "node src/dict-sync.ts" + "dict-sync": "node src/dict-sync.ts", + "make-admin": "node src/admin-cli.ts" }, "dependencies": { "fastify": "^5.12.5", diff --git a/api/src/admin-cli.ts b/api/src/admin-cli.ts new file mode 100644 index 00000000..30563000 --- /dev/null +++ b/api/src/admin-cli.ts @@ -0,0 +1,18 @@ +// Make an existing account an admin, from the server (the first admin cannot be made from the site). +// The person signs up on the site first, then: +// node src/admin-cli.ts admin@example.com +import { pool } from './db.ts'; +import { audit } from './admin.ts'; + +const email = (process.argv[2] ?? '').trim().toLowerCase(); +if (!email) { + console.error('usage: node src/admin-cli.ts '); + process.exit(1); +} +const { rows } = await pool.query(`UPDATE users SET role = 'admin', disabled_at = NULL WHERE email = $1 RETURNING id, email`, [email]); +if (rows[0]) { + await audit(null, 'promote', rows[0], { to: 'admin', via: 'server' }); + console.log(`${email} is now an admin`); +} else console.error(`no account with ${email}; sign up on the site first`); +await pool.end(); +process.exit(rows[0] ? 0 : 1); diff --git a/api/src/admin.test.ts b/api/src/admin.test.ts new file mode 100644 index 00000000..2935a877 --- /dev/null +++ b/api/src/admin.test.ts @@ -0,0 +1,68 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { authRoutes } from './auth.ts'; +import { adminRoutes, temporaryPassword } from './admin.ts'; +import { pool } from './db.ts'; + +after(() => pool.end()); + +test('temporary passwords: 12 characters, no look-alikes', () => { + const p = temporaryPassword(); + assert.match(p, /^[a-km-np-zA-HJ-NP-Z2-9]{12}$/); + assert.notEqual(p, temporaryPassword()); +}); + +test('admin panel: only admins; reset, disable, role, delete, self-protection, audit', async () => { + const app = Fastify(); + authRoutes(app); + adminRoutes(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': `admin-test-${run}` } }); + const signup = async (email: string) => (await call('POST', '/api/auth/signup', { email, password: 'pass-word-1' })).json(); + + const a = await signup(`admin-${run}@divan.test`), r = await signup(`reader-${run}@divan.test`); + await pool.query(`UPDATE users SET role = 'admin' WHERE id = $1`, [a.user.id]); + + assert.equal((await call('GET', '/api/admin/users', undefined, r.token)).statusCode, 403, 'readers are refused'); + assert.equal((await call('GET', '/api/admin/users')).statusCode, 401); + + const list = (await call('GET', `/api/admin/users?q=reader-${run}`, undefined, a.token)).json(); + assert.deepEqual(list.users.map((u: any) => u.email), [`reader-${run}@divan.test`]); + const rid = r.user.id; + + // password reset: new temporary password works, the old one and old sessions do not + const { password } = (await call('POST', `/api/admin/users/${rid}/password`, undefined, a.token)).json(); + assert.equal((await call('GET', '/api/auth/me', undefined, r.token)).statusCode, 401); + assert.equal((await call('POST', '/api/auth/signin', { email: `reader-${run}@divan.test`, password: 'pass-word-1' })).statusCode, 401); + const r2 = (await call('POST', '/api/auth/signin', { email: `reader-${run}@divan.test`, password })).json(); + assert.ok(r2.token); + + // disable ends sessions and blocks sign-in; enable restores + await call('POST', `/api/admin/users/${rid}/disable`, { disabled: true }, a.token); + assert.equal((await call('GET', '/api/auth/me', undefined, r2.token)).statusCode, 401); + assert.equal((await call('POST', '/api/auth/signin', { email: `reader-${run}@divan.test`, password })).statusCode, 403); + await call('POST', `/api/admin/users/${rid}/disable`, { disabled: false }, a.token); + assert.equal((await call('POST', '/api/auth/signin', { email: `reader-${run}@divan.test`, password })).statusCode, 200); + + // roles + assert.equal((await call('POST', `/api/admin/users/${rid}/role`, { role: 'emperor' }, a.token)).statusCode, 400); + assert.equal((await call('POST', `/api/admin/users/${rid}/role`, { role: 'admin' }, a.token)).statusCode, 200); + assert.equal((await call('POST', `/api/admin/users/${rid}/role`, { role: 'reader' }, a.token)).statusCode, 200); + + // an admin cannot lock themself out + for (const [path, body] of [['disable', { disabled: true }], ['role', { role: 'reader' }], ['delete', {}]] as const) + assert.equal((await call('POST', `/api/admin/users/${a.user.id}/${path}`, body, a.token)).statusCode, 400, path); + + assert.equal((await call('POST', `/api/admin/users/${rid}/delete`, undefined, a.token)).statusCode, 200); + assert.equal((await call('POST', `/api/admin/users/${rid}/delete`, undefined, a.token)).statusCode, 404); + + const log = (await call('GET', '/api/admin/audit', undefined, a.token)).json().entries + .filter((e: any) => e.target_email === `reader-${run}@divan.test`).map((e: any) => e.action).reverse(); + assert.deepEqual(log, ['password-reset', 'disable', 'enable', 'role', 'role', 'delete']); + + await pool.query('DELETE FROM users WHERE id = $1', [a.user.id]); + await pool.query(`DELETE FROM audit_log WHERE actor_email = $1 OR target_email = $1`, [`admin-${run}@divan.test`]); + await app.close(); +}); diff --git a/api/src/admin.ts b/api/src/admin.ts new file mode 100644 index 00000000..c6ceb44c --- /dev/null +++ b/api/src/admin.ts @@ -0,0 +1,105 @@ +// 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). +// 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/delete +// GET /api/admin/audit?page= +import { randomInt } from 'node:crypto'; +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; +const PAGE = 50; + +// readable temporary password: 12 characters without look-alikes (0/O, 1/l/I) +export function temporaryPassword() { + const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + return Array.from({ length: 12 }, () => chars[randomInt(chars.length)]).join(''); +} + +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: 'صرف ایڈمن کے لیے' }); + return u; +} + +export const audit = (actor: any, action: string, target: any, detail?: object) => + pool.query('INSERT INTO audit_log (actor_id, actor_email, action, target_id, target_email, detail) VALUES ($1, $2, $3, $4, $5, $6)', + [actor?.id ?? null, actor?.email ?? 'server', action, target?.id ?? null, target?.email ?? null, detail ?? null]); + +export function adminRoutes(app: FastifyInstance) { + app.get<{ Querystring: { q?: string; page?: string } }>('/api/admin/users', async (req, reply) => { + if (!(await requireAdmin(req, reply))) return; + const q = (req.query.q ?? '').trim().toLowerCase(), page = Math.max(1, Number(req.query.page) || 1); + const like = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%'; + const [count, rows] = await Promise.all([ + pool.query('SELECT count(*)::int AS n FROM users WHERE email LIKE $1', [like]), + pool.query( + `SELECT u.id, u.email, u.role, u.created_at, u.disabled_at, max(s.created_at) AS last_sign_in + FROM users u LEFT JOIN sessions s ON s.user_id = u.id WHERE u.email LIKE $1 + GROUP BY u.id ORDER BY u.created_at DESC LIMIT ${PAGE} OFFSET ${(page - 1) * PAGE}`, [like]), + ]); + return { total: count.rows[0].n, page, pageSize: PAGE, users: rows.rows.map((r) => ({ ...r, id: Number(r.id) })) }; + }); + + // one target user, never the acting admin themself for actions that could lock them out + const target = async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply, admin: any, notSelf: boolean) => { + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [Number(req.params.id) || 0])).rows[0]; + if (!u) return void reply.code(404).send({ error: 'صارف نہیں ملا' }); + if (notSelf && Number(u.id) === Number(admin.id)) return void reply.code(400).send({ error: 'یہ اپنے اکاؤنٹ پر نہیں ہو سکتا' }); + return u; + }; + + app.post<{ Params: { id: string } }>('/api/admin/users/:id/password', async (req, reply) => { + const admin = await requireAdmin(req, reply); if (!admin) return; + const u = await target(req, reply, admin, false); if (!u) return; + const password = temporaryPassword(); + await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [await hashPassword(password), u.id]); + await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]); + await audit(admin, 'password-reset', u); + return { password }; + }); + + app.post<{ Params: { id: string }; Body: { disabled?: boolean } }>('/api/admin/users/:id/disable', async (req, reply) => { + const admin = await requireAdmin(req, reply); if (!admin) return; + const u = await target(req, reply, admin, true); if (!u) return; + const disabled = req.body?.disabled !== false; + await pool.query('UPDATE users SET disabled_at = $1 WHERE id = $2', [disabled ? new Date() : null, u.id]); + if (disabled) await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]); + await audit(admin, disabled ? 'disable' : 'enable', u); + return { ok: true }; + }); + + app.post<{ Params: { id: string }; Body: { role?: string } }>('/api/admin/users/:id/role', async (req, reply) => { + const admin = await requireAdmin(req, reply); if (!admin) return; + const u = await target(req, reply, admin, true); if (!u) return; + 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]); + await audit(admin, 'role', u, { from: u.role, to: role }); + return { ok: true }; + }); + + app.post<{ Params: { id: string } }>('/api/admin/users/:id/delete', async (req, reply) => { + const admin = await requireAdmin(req, reply); if (!admin) return; + const u = await target(req, reply, admin, true); if (!u) return; + await pool.query('DELETE FROM users WHERE id = $1', [u.id]); + await audit(admin, 'delete', u); + return { ok: true }; + }); + + app.get<{ Querystring: { page?: string } }>('/api/admin/audit', async (req, reply) => { + if (!(await requireAdmin(req, reply))) return; + const page = Math.max(1, Number(req.query.page) || 1); + const [count, rows] = await Promise.all([ + pool.query('SELECT count(*)::int AS n FROM audit_log'), + pool.query(`SELECT at, actor_email, action, target_email, detail FROM audit_log ORDER BY at DESC, id DESC LIMIT ${PAGE} OFFSET ${(page - 1) * PAGE}`), + ]); + return { total: count.rows[0].n, page, pageSize: PAGE, entries: rows.rows }; + }); +} diff --git a/api/src/auth.ts b/api/src/auth.ts index d6de0f27..3f1ff143 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -13,6 +13,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import { pool } from './db.ts'; const SESSION_DAYS = 30; +export { sha }; const KDF = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; const derive = (password: string, salt: Buffer) => @@ -55,8 +56,10 @@ export function limiter(max: number, windowMs: number) { } const signinByIp = limiter(20, 15 * 60_000), signinByEmail = limiter(8, 15 * 60_000), signupByIp = limiter(5, 60 * 60_000); -const sha = (t: string) => createHash('sha256').update(t).digest('hex'); -const publicUser = (u: any) => ({ id: Number(u.id), email: u.email, created_at: u.created_at }); +function sha(t: string) { + return createHash('sha256').update(t).digest('hex'); +} +export const publicUser = (u: any) => ({ id: Number(u.id), email: u.email, role: u.role as string, created_at: u.created_at }); async function newSession(userId: number) { const token = randomBytes(32).toString('base64url'); @@ -70,7 +73,7 @@ export async function sessionUser(req: FastifyRequest) { if (!token) return null; const { rows } = await pool.query( `SELECT u.*, s.id AS sid, s.expires_at < now() + interval '${SESSION_DAYS / 2} days' AS renew - FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = $1 AND s.expires_at > now()`, [sha(token)]); + FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = $1 AND s.expires_at > now() AND u.disabled_at IS NULL`, [sha(token)]); const u = rows[0]; if (u?.renew) await pool.query(`UPDATE sessions SET expires_at = now() + interval '${SESSION_DAYS} days' WHERE id = $1`, [u.sid]); return u ?? null; @@ -96,6 +99,7 @@ export function authRoutes(app: FastifyInstance) { const u = (await pool.query('SELECT * FROM users WHERE email = $1', [email])).rows[0]; const ok = await verifyPassword(password, u?.password_hash ?? DUMMY); if (!u || !ok) return reply.code(401).send({ error: 'ای میل یا پاس ورڈ درست نہیں' }); + if (u.disabled_at) return reply.code(403).send({ error: 'یہ اکاؤنٹ معطل ہے۔ ایڈمن سے رابطہ کریں۔' }); return { token: await newSession(u.id), user: publicUser(u) }; }); diff --git a/api/src/server.ts b/api/src/server.ts index e7673e10..ea9ff9d5 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -4,12 +4,14 @@ // GET /api/search?q=&poet=&page= // GET /api/word?w= Wiktionary meanings and pronunciation (sidebar) // /api/auth/* accounts (see auth.ts) +// /api/admin/* admin panel (see admin.ts) // GET /health import Fastify from 'fastify'; import { pool } from './db.ts'; import { likePatterns, normalise, terms } from './urdu.ts'; import { lookup, PUNCT } from './dictionary.ts'; import { authRoutes } from './auth.ts'; +import { adminRoutes } from './admin.ts'; const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }); const PAGE_SIZE = 20; @@ -136,6 +138,7 @@ app.get<{ Querystring: { w?: string } }>('/api/word', async (req, reply) => { }); authRoutes(app); +adminRoutes(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 be0860ff..a216ee6a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -96,3 +96,17 @@ CREATE TABLE IF NOT EXISTS sessions ( expires_at timestamptz NOT NULL ); CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id); +-- admin panel (api/src/admin.ts): roles, disabled accounts, audit log of admin actions +ALTER TABLE users ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'reader'; -- reader | admin (moderators: #29) +ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled_at timestamptz; -- set: cannot sign in +CREATE TABLE IF NOT EXISTS audit_log ( + id bigserial PRIMARY KEY, + at timestamptz NOT NULL DEFAULT now(), + actor_id bigint REFERENCES users(id) ON DELETE SET NULL, + actor_email text NOT NULL, -- kept when the actor's account is deleted + action text NOT NULL, -- password-reset | disable | enable | role | delete | promote + target_id bigint, -- the user acted on (no FK: deleted users stay in the log) + target_email text, + detail jsonb +); +CREATE INDEX IF NOT EXISTS audit_log_at ON audit_log(at DESC); diff --git a/web/src/components/AdminNav.astro b/web/src/components/AdminNav.astro new file mode 100644 index 00000000..450cd8a0 --- /dev/null +++ b/web/src/components/AdminNav.astro @@ -0,0 +1,7 @@ +--- +const { current } = Astro.props as { current: 'users' | 'audit' }; +--- + diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index b2a36fc7..10674a0f 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -41,6 +41,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
+ {Astro.locals.user?.role === 'admin' && } {Astro.locals.user ? : } diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 2c529830..47e6fe6d 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -3,7 +3,7 @@ import type { AstroCookies } from 'astro'; const API = process.env.API_URL ?? 'http://127.0.0.1:4100'; export const COOKIE = 'divan_session'; -export type User = { id: number; email: string; created_at: string }; +export type User = { id: number; email: string; role: string; created_at: string }; // call an /api/auth endpoint as the reader (their token, their IP for rate limits) export async function auth(path: string, opts: { token?: string; body?: object; ip?: string } = {}) { @@ -21,3 +21,13 @@ export async function auth(path: string, opts: { token?: string; body?: object; export const setSession = (cookies: AstroCookies, token: string, secure: boolean) => cookies.set(COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 30 * 86400 }); + +// call any API endpoint as the signed-in reader (admin pages) +export async function asUser(token: string, path: string, body?: object) { + const res = await fetch(`${API}${path}`, { + method: body ? 'POST' : 'GET', + headers: { authorization: `Bearer ${token}`, ...(body && { 'content-type': 'application/json' }) }, + body: body ? JSON.stringify(body) : undefined, + }); + return { ok: res.ok, status: res.status, data: await res.json().catch(() => ({})) }; +} diff --git a/web/src/pages/admin/audit.astro b/web/src/pages/admin/audit.astro new file mode 100644 index 00000000..0240bda2 --- /dev/null +++ b/web/src/pages/admin/audit.astro @@ -0,0 +1,44 @@ +--- +// Admin: audit log of admin actions +import Base from '../../layouts/Base.astro'; +import AdminNav from '../../components/AdminNav.astro'; +import { asUser, COOKIE } from '../../lib/auth'; +import { ud } from '../../lib/urdu'; + +const me = Astro.locals.user; +if (!me) return Astro.redirect('/signin?next=/admin/audit'); +if (me.role !== 'admin') return new Response('صرف ایڈمن کے لیے', { status: 403 }); +const page = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1); +const log = (await asUser(Astro.cookies.get(COOKIE)!.value, `/api/admin/audit?page=${page}`)).data; +const pages = Math.ceil(log.total / log.pageSize); +const ACTION: Record = { + 'password-reset': 'پاس ورڈ ری سیٹ', disable: 'معطل', enable: 'بحال', role: 'کردار', delete: 'حذف', promote: 'ایڈمن (سرور سے)', +}; +const ROLE: Record = { admin: 'ایڈمن', reader: 'قاری' }; +const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' ')); +--- + +

ایڈمن

+ + + + + {log.entries.map((e: any) => ( + + + + + + + + ))} + +
وقت (UTC)ایڈمنعملصارفتفصیل
{when(e.at)}{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('— ← ', '') : ''}
+ {pages > 1 && ( + + )} + diff --git a/web/src/pages/admin/index.astro b/web/src/pages/admin/index.astro new file mode 100644 index 00000000..b9047bd1 --- /dev/null +++ b/web/src/pages/admin/index.astro @@ -0,0 +1,97 @@ +--- +// Admin: users. Password resets happen here on a reader's request (no email server yet). +import Base from '../../layouts/Base.astro'; +import AdminNav from '../../components/AdminNav.astro'; +import { asUser, COOKIE } from '../../lib/auth'; +import { ud } from '../../lib/urdu'; + +const me = Astro.locals.user; +if (!me) return Astro.redirect('/signin?next=/admin'); +if (me.role !== 'admin') return new Response('صرف ایڈمن کے لیے', { status: 403 }); +const token = Astro.cookies.get(COOKIE)!.value; + +let error = '', done = '', tempPassword = '', tempFor = ''; +if (Astro.request.method === 'POST') { + const f = await Astro.request.formData(); + const id = String(f.get('id')), email = String(f.get('email') ?? ''); + const act = String(f.get('act')); + const r = act === 'reset' ? await asUser(token, `/api/admin/users/${id}/password`, {}) + : act === 'disable' || act === 'enable' ? await asUser(token, `/api/admin/users/${id}/disable`, { disabled: act === 'disable' }) + : act === 'role' ? await asUser(token, `/api/admin/users/${id}/role`, { role: f.get('role') }) + : act === 'delete' ? await asUser(token, `/api/admin/users/${id}/delete`, {}) + : { ok: false, data: { error: 'نامعلوم عمل' } }; + if (!r.ok) error = r.data.error ?? 'کچھ غلط ہو گیا'; + else if (act === 'reset') [tempPassword, tempFor] = [r.data.password, email]; + else done = { disable: 'اکاؤنٹ معطل کر دیا گیا', enable: 'اکاؤنٹ بحال کر دیا گیا', role: 'کردار بدل دیا گیا', delete: 'اکاؤنٹ حذف کر دیا گیا' }[act] + ` (${email})`; +} + +const q = Astro.url.searchParams.get('q') ?? '', page = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1); +const list = (await asUser(token, `/api/admin/users?q=${encodeURIComponent(q)}&page=${page}`)).data; +const pages = Math.ceil(list.total / list.pageSize); +const date = (d: string | null) => (d ? ud(new Date(d).toISOString().slice(0, 10)) : '—'); +const link = (n: number) => `/admin?q=${encodeURIComponent(q)}&page=${n}`; +--- + +

ایڈمن

+ + {error && } + {done &&

{done}

} + {tempPassword && ( +
+

{tempFor} کا عارضی پاس ورڈ (صرف ایک بار دکھایا جا رہا ہے):

+

{tempPassword}

+

یہ پاس ورڈ صارف کو دیں؛ وہ لاگ ان کر کے اپنے اکاؤنٹ کے صفحے سے اسے بدل لیں۔

+
+ )} + + +

{ud(list.total)} صارفین

+ + + + + {list.users.map((u: any) => ( + + + + + + + + + ))} + +
ای میلکردارشمولیتآخری لاگ انحالتاقدامات
{u.email}{u.id === me.id && (آپ)}{u.role === 'admin' ? 'ایڈمن' : 'قاری'}{date(u.created_at)}{date(u.last_sign_in)}{u.disabled_at ? 'معطل' : 'فعال'} +
+ + +
+ {u.id !== me.id && ( + <> +
+ + +
+
+ + + +
+
+ + +
+ + )} +
+ {pages > 1 && ( + + )} + diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 53c43f03..6b68ffcf 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -186,3 +186,21 @@ h1 + .muted { text-align: center; margin-top: 0; } .selmenu[hidden] { display: none; } .selmenu button { font: inherit; font-size: .85rem; color: #f3ead8; background: none; border: 0; border-radius: 7px; padding: 2px 10px; cursor: pointer; } .selmenu button:hover, .selmenu button:focus-visible { background: rgb(201 160 80 / .25); outline: none; } + +/* admin panel */ +.admin-nav { display: flex; justify-content: center; gap: 8px; margin: 0 0 16px; } +.admin-nav a { padding: 2px 14px; border: 1.5px solid var(--border); border-radius: 999px; text-decoration: none; color: var(--ink); } +.admin-nav a[aria-current='page'] { border-color: var(--brand); color: var(--brand); } +.admin-search { display: flex; gap: 6px; max-width: 420px; margin: 0 auto 8px; } +.admin-search input { flex: 1; font: inherit; padding: 4px 12px; border: 1.5px solid var(--border); border-radius: 10px; background: var(--paper); color: var(--ink); } +.admin-search button, .admin-table button { font: inherit; font-size: .8rem; padding: 1px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); cursor: pointer; } +.admin-table button:hover { border-color: var(--brand); color: var(--brand); } +.admin-table button.danger { color: var(--brand); } +.admin-table { width: 100%; border-collapse: collapse; font-size: .88rem; margin: 8px 0; } +.admin-table th, .admin-table td { padding: 6px 8px; border-bottom: 1px dashed var(--gold-light); text-align: start; vertical-align: middle; } +.admin-table tr.disabled td { opacity: .55; } +.admin-table .actions { display: flex; flex-wrap: wrap; gap: 4px; } +.admin-table .actions form { margin: 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; } } -- 2.47.3