Merge pull request 'Profile: full name and bio' (#43) from feature/profile into main

Reviewed-on: #43
This commit is contained in:
anas 2026-10-08 21:45:19 +00:00
commit 531d4e9f69
7 changed files with 43 additions and 5 deletions

View File

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

View File

@ -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: 'دوبارہ لاگ ان کریں' });

View File

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

View File

@ -43,7 +43,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
<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="/account" title={Astro.locals.user.email}>{Astro.locals.user.full_name.split(' ')[0] || 'اکاؤنٹ'}</a>
: <a class="account-link" href={`/signin?next=${encodeURIComponent(Astro.url.pathname)}`}>لاگ ان</a>}
<button type="button" id="font-toggle" aria-label="خط بدلیں">نسخ</button>
<button type="button" id="theme-toggle" aria-label="روشن یا تاریک">◐</button>

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

View File

@ -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);
---
<Base title="میرا اکاؤنٹ">
<h1>میرا اکاؤنٹ</h1>
<h1>{user.full_name || 'میرا اکاؤنٹ'}</h1>
<p class="muted center"><bdi dir="ltr">{user.email}</bdi> · رکنیت: {ud(since.getFullYear())}</p>
{user.bio && <p class="profile-bio">{user.bio}</p>}
{error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done}</p>}
<form method="post" class="account-form">
<h2>پروفائل</h2>
<input type="hidden" name="act" value="profile" />
<label>پورا نام<input name="full_name" value={user.full_name} maxlength="100" dir="auto" autocomplete="name" /></label>
<label>تعارف<textarea name="bio" maxlength="1000" rows="4" dir="auto">{user.bio}</textarea></label>
<button type="submit">محفوظ کریں</button>
</form>
<form method="post" class="account-form">
<input type="hidden" name="act" value="signout" />
<button type="submit">لاگ آؤٹ</button>

View File

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