Admin panel: users, password resets, roles, audit log #39

Merged
anas merged 1 commits from feature/admin-panel into main 2026-10-08 21:10:05 +00:00
14 changed files with 397 additions and 5 deletions

View File

@ -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.

View File

@ -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",

18
api/src/admin-cli.ts Normal file
View File

@ -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 <email of an existing account>');
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);

68
api/src/admin.test.ts Normal file
View File

@ -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();
});

105
api/src/admin.ts Normal file
View File

@ -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 };
});
}

View File

@ -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) };
});

View File

@ -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' });

View File

@ -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);

View File

@ -0,0 +1,7 @@
---
const { current } = Astro.props as { current: 'users' | 'audit' };
---
<nav class="admin-nav">
<a href="/admin" aria-current={current === 'users' ? 'page' : undefined}>صارفین</a>
<a href="/admin/audit" aria-current={current === 'audit' ? 'page' : undefined}>ریکارڈ</a>
</nav>

View File

@ -41,6 +41,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
</button>
</form>
<div class="toggles">
{Astro.locals.user?.role === 'admin' && <a class="account-link" href="/admin">ایڈمن</a>}
{Astro.locals.user
? <a class="account-link" href="/account" title={Astro.locals.user.email}>اکاؤنٹ</a>
: <a class="account-link" href={`/signin?next=${encodeURIComponent(Astro.url.pathname)}`}>لاگ ان</a>}

View File

@ -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(() => ({})) };
}

View File

@ -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<string, string> = {
'password-reset': 'پاس ورڈ ری سیٹ', disable: 'معطل', enable: 'بحال', role: 'کردار', delete: 'حذف', promote: 'ایڈمن (سرور سے)',
};
const ROLE: Record<string, string> = { admin: 'ایڈمن', reader: 'قاری' };
const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' '));
---
<Base title="ایڈمن: ریکارڈ">
<h1>ایڈمن</h1>
<AdminNav current="audit" />
<table class="admin-table">
<thead><tr><th>وقت (UTC)</th><th>ایڈمن</th><th>عمل</th><th>صارف</th><th>تفصیل</th></tr></thead>
<tbody>
{log.entries.map((e: any) => (
<tr>
<td>{when(e.at)}</td>
<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>
</tr>
))}
</tbody>
</table>
{pages > 1 && (
<nav class="pager">
{page > 1 && <a href={`/admin/audit?page=${page - 1}`}>‹ پچھلا</a>}
<span class="muted">صفحہ {ud(page)} از {ud(pages)}</span>
{page < pages && <a href={`/admin/audit?page=${page + 1}`}>اگلا ›</a>}
</nav>
)}
</Base>

View File

@ -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}`;
---
<Base title="ایڈمن: صارفین">
<h1>ایڈمن</h1>
<AdminNav current="users" />
{error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done}</p>}
{tempPassword && (
<div class="temp-password" role="status">
<p><bdi dir="ltr">{tempFor}</bdi> کا عارضی پاس ورڈ (صرف ایک بار دکھایا جا رہا ہے):</p>
<p><code dir="ltr">{tempPassword}</code></p>
<p class="muted">یہ پاس ورڈ صارف کو دیں؛ وہ لاگ ان کر کے اپنے اکاؤنٹ کے صفحے سے اسے بدل لیں۔</p>
</div>
)}
<form method="get" class="admin-search">
<input type="search" name="q" value={q} placeholder="ای میل سے تلاش" dir="ltr" />
<button type="submit">تلاش</button>
</form>
<p class="muted center">{ud(list.total)} صارفین</p>
<table class="admin-table">
<thead><tr><th>ای میل</th><th>کردار</th><th>شمولیت</th><th>آخری لاگ ان</th><th>حالت</th><th>اقدامات</th></tr></thead>
<tbody>
{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>{date(u.created_at)}</td>
<td>{date(u.last_sign_in)}</td>
<td>{u.disabled_at ? 'معطل' : 'فعال'}</td>
<td class="actions">
<form method="post" onsubmit="return confirm('اس صارف کا پاس ورڈ ری سیٹ کریں؟')">
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
<button name="act" value="reset">پاس ورڈ ری سیٹ</button>
</form>
{u.id !== me.id && (
<>
<form method="post">
<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">
<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>
</form>
<form method="post" onsubmit="return confirm('یہ اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔ جاری رکھیں؟')">
<input type="hidden" name="id" value={u.id} /><input type="hidden" name="email" value={u.email} />
<button name="act" value="delete" class="danger">حذف</button>
</form>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
{pages > 1 && (
<nav class="pager">
{page > 1 && <a href={link(page - 1)}>‹ پچھلا</a>}
<span class="muted">صفحہ {ud(page)} از {ud(pages)}</span>
{page < pages && <a href={link(page + 1)}>اگلا ›</a>}
</nav>
)}
</Base>

View File

@ -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; } }