diff --git a/api/src/auth.test.ts b/api/src/auth.test.ts index 57bdbb05..b95fb9d5 100644 --- a/api/src/auth.test.ts +++ b/api/src/auth.test.ts @@ -48,6 +48,14 @@ test('HTTP flow: sign up, sign in, wrong password, change password, delete', asy assert.equal((await call('GET', '/api/auth/me', undefined, t1)).statusCode, 401, 'other sessions signed out'); assert.equal((await call('GET', '/api/auth/me', undefined, t2)).statusCode, 200, 'this session kept'); + // profile: Urdu name and bio, trimmed, control characters dropped, length-limited + const prof = (await call('POST', '/api/auth/profile', { full_name: ' مرزا اسد اللہ\u0007 خان ', bio: 'شاعر۔ '.repeat(300) }, t2)).json().user; + assert.equal(prof.full_name, 'مرزا اسد اللہ خان'); + assert.equal(prof.bio.length, 1000); + assert.equal((await call('GET', '/api/auth/me', undefined, t2)).json().user.full_name, 'مرزا اسد اللہ خان'); + assert.equal((await call('POST', '/api/auth/profile', { full_name: '', bio: '' }, t2)).json().user.full_name, '', 'cleared'); + assert.equal((await call('POST', '/api/auth/profile', { full_name: 'x' })).statusCode, 401); + await call('POST', '/api/auth/signout', undefined, t2); assert.equal((await call('GET', '/api/auth/me', undefined, t2)).statusCode, 401); diff --git a/api/src/auth.ts b/api/src/auth.ts index 3f1ff143..db881dd3 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -8,6 +8,7 @@ // POST /api/auth/signout Bearer token // POST /api/auth/password Bearer token {current, next} -> other sessions signed out // POST /api/auth/delete Bearer token {password} -> account and its data deleted +// POST /api/auth/profile Bearer token {full_name, bio} (Urdu or any text; trimmed, length-limited) import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; import type { FastifyInstance, FastifyRequest } from 'fastify'; import { pool } from './db.ts'; @@ -59,7 +60,9 @@ const signinByIp = limiter(20, 15 * 60_000), signinByEmail = limiter(8, 15 * 60_ 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 }); +export const publicUser = (u: any) => ({ + id: Number(u.id), email: u.email, role: u.role as string, created_at: u.created_at, full_name: u.full_name ?? '', bio: u.bio ?? '', +}); async function newSession(userId: number) { const token = randomBytes(32).toString('base64url'); @@ -125,6 +128,15 @@ export function authRoutes(app: FastifyInstance) { return { ok: true }; }); + app.post<{ Body: { full_name?: string; bio?: string } }>('/api/auth/profile', async (req, reply) => { + const u = await sessionUser(req); + if (!u) return reply.code(401).send({ error: 'دوبارہ لاگ ان کریں' }); + const text = (v: unknown, max: number) => String(v ?? '').normalize('NFC').replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '').trim().slice(0, max); + const full_name = text(req.body?.full_name, 100).replace(/\s+/g, ' '), bio = text(req.body?.bio, 1000); + const { rows } = await pool.query('UPDATE users SET full_name = $1, bio = $2 WHERE id = $3 RETURNING *', [full_name || null, bio || null, u.id]); + return { user: publicUser(rows[0]) }; + }); + app.post<{ Body: { password?: string } }>('/api/auth/delete', async (req, reply) => { const u = await sessionUser(req); if (!u) return reply.code(401).send({ error: 'دوبارہ لاگ ان کریں' }); diff --git a/db/schema.sql b/db/schema.sql index 34f5a888..cf7efce8 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -123,3 +123,6 @@ CREATE TABLE IF NOT EXISTS grants ( CHECK ((scope = 'all') = (scope_id IS NULL)) ); CREATE INDEX IF NOT EXISTS grants_user ON grants(user_id); +-- profile (owner request): full name and bio, usually in Urdu +ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name text; -- up to 100 characters +ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text; -- up to 1,000 characters diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 10674a0f..32148a46 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -43,7 +43,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
{Astro.locals.user?.role === 'admin' && ایڈمن} {Astro.locals.user - ? اکاؤنٹ + ? {Astro.locals.user.full_name.split(' ')[0] || 'اکاؤنٹ'} : لاگ ان} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 47e6fe6d..8c47667d 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; role: string; created_at: string }; +export type User = { id: number; email: string; role: string; created_at: string; full_name: string; bio: 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 } = {}) { diff --git a/web/src/pages/account.astro b/web/src/pages/account.astro index 5b389e40..bc1148b4 100644 --- a/web/src/pages/account.astro +++ b/web/src/pages/account.astro @@ -15,6 +15,10 @@ if (Astro.request.method === 'POST') { Astro.cookies.delete(COOKIE, { path: '/' }); return Astro.redirect('/'); } + if (act === 'profile') { + const r = await auth('profile', { token, body: { full_name: form.get('full_name'), bio: form.get('bio') } }); + r.ok ? ((done = 'پروفائل محفوظ ہو گیا'), Object.assign(user, r.data.user)) : (error = r.data.error); + } if (act === 'password') { if (form.get('next') !== form.get('next2')) error = 'نئے پاس ورڈ ایک جیسے نہیں'; else { @@ -34,11 +38,20 @@ if (Astro.request.method === 'POST') { const since = new Date(user.created_at); --- -

میرا اکاؤنٹ

+

{user.full_name || 'میرا اکاؤنٹ'}

{user.email} · رکنیت: {ud(since.getFullYear())}

+ {user.bio &&

{user.bio}

} {error && } {done &&

{done}

} +
+

پروفائل

+ + + + +
+
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 134efda4..6be07401 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -174,7 +174,9 @@ h1 + .muted { text-align: center; margin-top: 0; } .account-form h2 { font-size: 1.1rem; margin: 6px 0 0; } .account-form label { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; } .account-form input { font: inherit; padding: 6px 12px; border: 1.5px solid var(--border); border-radius: 10px; background: var(--paper); color: var(--ink); } -.account-form input:focus { outline: none; border-color: var(--gold); } +.account-form textarea { font: inherit; line-height: 1.9; padding: 6px 12px; border: 1.5px solid var(--border); border-radius: 10px; background: var(--paper); color: var(--ink); resize: vertical; } +.account-form input:focus, .account-form textarea:focus { outline: none; border-color: var(--gold); } +.profile-bio { max-width: 520px; margin: 0 auto 12px; text-align: center; white-space: pre-line; } .account-form button { font: inherit; padding: 6px 14px; border: 1.5px solid var(--gold); border-radius: 10px; background: var(--inner); color: var(--ink); cursor: pointer; } .account-form button:hover { border-color: var(--brand); color: var(--brand); } .account-form.danger button { border-color: var(--brand); color: var(--brand); }