Merge pull request 'Profile: full name and bio' (#43) from feature/profile into main
Reviewed-on: #43
This commit is contained in:
commit
531d4e9f69
@ -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);
|
||||
|
||||
|
||||
@ -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: 'دوبارہ لاگ ان کریں' });
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 } = {}) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user