From e409adc66862ab8aec5df4199f0c148432420033 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Thu, 8 Oct 2026 22:53:51 +0200 Subject: [PATCH] Accounts: email and password sign-up, sign-in/out, change password, delete account (#18) - API (api/src/auth.ts): scrypt password hashes; random session tokens stored only as SHA-256; 30-day sliding sessions; rate limits on sign-in (per IP and per email) and sign-up; changing the password signs out other devices; deleting the account removes its data. - Site: /signup, /signin (returns to the page the reader came from), /account; header link; plain forms, no JavaScript needed. Session in an HTTP-only, SameSite=Lax cookie (Secure over HTTPS). - CSRF: Astro's origin check, with the site's hostnames listed (SITE_HOSTS) so its own form posts pass and other sites' are refused. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- api/src/auth.test.ts | 71 +++++++++++++++++++ api/src/auth.ts | 131 ++++++++++++++++++++++++++++++++++++ api/src/server.ts | 4 ++ db/schema.sql | 15 +++++ web/astro.config.mjs | 6 ++ web/src/env.d.ts | 5 ++ web/src/layouts/Base.astro | 3 + web/src/lib/auth.ts | 23 +++++++ web/src/middleware.ts | 14 ++++ web/src/pages/account.astro | 63 +++++++++++++++++ web/src/pages/signin.astro | 31 +++++++++ web/src/pages/signup.astro | 32 +++++++++ web/src/styles/global.css | 14 ++++ 14 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 api/src/auth.test.ts create mode 100644 api/src/auth.ts create mode 100644 web/src/env.d.ts create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/middleware.ts create mode 100644 web/src/pages/account.astro create mode 100644 web/src/pages/signin.astro create mode 100644 web/src/pages/signup.astro diff --git a/README.md b/README.md index 83a5a022..6d5eaecf 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ npm test # Urdu normaliser tests cd ../web && npm install && npm run build && npm start # site on http://127.0.0.1:4200 ``` -Settings: `DATABASE_URL` (API, default `postgres://divan:divan_local@localhost:5433/divan`), `PORT`/`HOST`; `API_URL` (web, default `http://127.0.0.1:4100`). +Settings: `DATABASE_URL` (API, default `postgres://divan:divan_local@localhost:5433/divan`), `PORT`/`HOST`; `API_URL` (web, default `http://127.0.0.1:4100`); `SITE_HOSTS` (web, at build time: the site's hostnames, comma-separated, default `127.0.0.1,localhost`; form posts from other origins are refused). The import upserts, so re-running it after a divan-data sync applies the changes. diff --git a/api/src/auth.test.ts b/api/src/auth.test.ts new file mode 100644 index 00000000..57bdbb05 --- /dev/null +++ b/api/src/auth.test.ts @@ -0,0 +1,71 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { hashPassword, verifyPassword, normaliseEmail, validEmail, passwordProblem, limiter, authRoutes } from './auth.ts'; +import { pool } from './db.ts'; + +after(() => pool.end()); + +test('passwords: salted scrypt, verified in constant time, wrong ones rejected', async () => { + const h = await hashPassword('دیوان-غالب-123'); + assert.match(h, /^scrypt\$32768\$8\$1\$[\w-]+\$[\w-]+$/); + assert.notEqual(h, await hashPassword('دیوان-غالب-123'), 'a new salt each time'); + assert.equal(await verifyPassword('دیوان-غالب-123', h), true); + assert.equal(await verifyPassword('دیوان-غالب-124', h), false); +}); + +test('email and password checks', () => { + assert.equal(normaliseEmail(' Anas@Example.COM '), 'anas@example.com'); + assert.ok(validEmail('a@b.pk') && !validEmail('a@b') && !validEmail('a b@c.pk')); + assert.equal(passwordProblem('1234567'), 'پاس ورڈ کم از کم ۸ حروف کا ہو'); + assert.equal(passwordProblem('12345678'), null); +}); + +test('rate limiter: max per window, then resets', () => { + const allow = limiter(2, 1000); + assert.deepEqual([allow('ip', 0), allow('ip', 1), allow('ip', 2), allow('other', 2)], [true, true, false, true]); + assert.equal(allow('ip', 1001), true); +}); + +test('HTTP flow: sign up, sign in, wrong password, change password, delete', async () => { + const app = Fastify(); + authRoutes(app); + const email = `test-${Date.now()}@divan.test`; + 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': `t-${email}` } }); + + const up = await call('POST', '/api/auth/signup', { email, password: 'pass-word-1' }); + assert.equal(up.statusCode, 200); + const t1 = up.json().token; + assert.equal((await call('POST', '/api/auth/signup', { email: email.toUpperCase(), password: 'pass-word-1' })).statusCode, 409, 'one account per email'); + assert.equal((await call('GET', '/api/auth/me', undefined, t1)).json().user.email, email); + + assert.equal((await call('POST', '/api/auth/signin', { email, password: 'wrong-pass' })).statusCode, 401); + const t2 = (await call('POST', '/api/auth/signin', { email, password: 'pass-word-1' })).json().token; + + assert.equal((await call('POST', '/api/auth/password', { current: 'wrong-pass', next: 'pass-word-2' }, t2)).statusCode, 403); + assert.equal((await call('POST', '/api/auth/password', { current: 'pass-word-1', next: 'pass-word-2' }, t2)).statusCode, 200); + 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'); + + await call('POST', '/api/auth/signout', undefined, t2); + assert.equal((await call('GET', '/api/auth/me', undefined, t2)).statusCode, 401); + + const t3 = (await call('POST', '/api/auth/signin', { email, password: 'pass-word-2' })).json().token; + assert.equal((await call('POST', '/api/auth/delete', { password: 'wrong' }, t3)).statusCode, 403); + assert.equal((await call('POST', '/api/auth/delete', { password: 'pass-word-2' }, t3)).statusCode, 200); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM users WHERE email = $1', [email])).rows[0].n, 0); + assert.equal((await pool.query("SELECT count(*)::int AS n FROM sessions s LEFT JOIN users u ON u.id = s.user_id WHERE u.id IS NULL")).rows[0].n, 0); + await app.close(); +}); + +test('sign-in is rate limited per email', async () => { + const app = Fastify(); + authRoutes(app); + const email = `limit-${Date.now()}@divan.test`; + const codes = []; + for (let i = 0; i < 10; i++) + codes.push((await app.inject({ method: 'POST', url: '/api/auth/signin', payload: { email, password: 'x' }, headers: { 'x-client-ip': `ip-${i}` } })).statusCode); + assert.deepEqual(codes, [401, 401, 401, 401, 401, 401, 401, 401, 429, 429]); + await app.close(); +}); diff --git a/api/src/auth.ts b/api/src/auth.ts new file mode 100644 index 00000000..d6de0f27 --- /dev/null +++ b/api/src/auth.ts @@ -0,0 +1,131 @@ +// Accounts: email address and password only (no email is sent; owner decision 2026-10-08). +// Passwords: scrypt with a per-user salt. Sessions: a random token held by the site in an HTTP-only +// cookie; the database stores only its SHA-256, so a database leak does not give working sessions. +// The API is private (only the site calls it), so the site passes the reader's IP in x-client-ip. +// POST /api/auth/signup {email, password} -> {token, user} +// POST /api/auth/signin {email, password} -> {token, user} +// GET /api/auth/me Bearer token -> {user} +// 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 +import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { pool } from './db.ts'; + +const SESSION_DAYS = 30; +const KDF = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; + +const derive = (password: string, salt: Buffer) => + new Promise((ok, fail) => scrypt(password.normalize('NFC'), salt, 32, KDF, (e, k) => (e ? fail(e) : ok(k)))); + +// "scrypt$N$r$p$salt$hash" (base64url), so the cost can be raised later without breaking old hashes +export async function hashPassword(password: string) { + const salt = randomBytes(16); + return `scrypt$${KDF.N}$${KDF.r}$${KDF.p}$${salt.toString('base64url')}$${(await derive(password, salt)).toString('base64url')}`; +} + +export async function verifyPassword(password: string, stored: string) { + const [alg, N, r, p, salt, hash] = stored.split('$'); + if (alg !== 'scrypt') return false; + const key = await new Promise((ok, fail) => + scrypt(password.normalize('NFC'), Buffer.from(salt, 'base64url'), 32, { N: +N, r: +r, p: +p, maxmem: KDF.maxmem }, (e, k) => (e ? fail(e) : ok(k)))); + return timingSafeEqual(key, Buffer.from(hash, 'base64url')); +} +// compared against when the email is unknown, so a wrong email takes as long as a wrong password +const DUMMY = await hashPassword(randomBytes(16).toString('hex')); + +export const normaliseEmail = (e: unknown) => (typeof e === 'string' ? e.trim().toLowerCase() : ''); +export const validEmail = (e: string) => e.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); +export const passwordProblem = (p: unknown) => + typeof p !== 'string' ? 'پاس ورڈ درکار ہے' : p.length < 8 ? 'پاس ورڈ کم از کم ۸ حروف کا ہو' : p.length > 200 ? 'پاس ورڈ بہت لمبا ہے' : null; + +// fixed-window limits per key (IP or email); ponytail: in-process memory, fine for one API process; +// move to PostgreSQL or Redis if the API ever runs as several processes +export function limiter(max: number, windowMs: number) { + const hits = new Map(); + return (key: string, now = Date.now()) => { + const h = hits.get(key); + if (!h || h.until <= now) { + hits.set(key, { n: 1, until: now + windowMs }); + if (hits.size > 10_000) for (const [k, v] of hits) if (v.until <= now) hits.delete(k); + return true; + } + return ++h.n <= max; + }; +} +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 }); + +async function newSession(userId: number) { + const token = randomBytes(32).toString('base64url'); + await pool.query(`INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, now() + interval '${SESSION_DAYS} days')`, [sha(token), userId]); + return token; +} + +// the signed-in user for a Bearer token; sliding expiry (renewed when less than half is left) +export async function sessionUser(req: FastifyRequest) { + const token = req.headers.authorization?.match(/^Bearer (\S+)$/)?.[1]; + 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)]); + 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; +} + +const ip = (req: FastifyRequest) => (req.headers['x-client-ip'] as string) || req.ip; + +export function authRoutes(app: FastifyInstance) { + app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signup', async (req, reply) => { + if (!signupByIp(ip(req))) return reply.code(429).send({ error: 'بہت زیادہ کوششیں۔ کچھ دیر بعد دوبارہ کوشش کریں۔' }); + const email = normaliseEmail(req.body?.email), problem = passwordProblem(req.body?.password); + if (!validEmail(email)) return reply.code(400).send({ error: 'درست ای میل پتہ لکھیں' }); + if (problem) return reply.code(400).send({ error: problem }); + const { rows } = await pool.query( + 'INSERT INTO users (email, password_hash) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *', [email, await hashPassword(req.body!.password!)]); + if (!rows[0]) return reply.code(409).send({ error: 'اس ای میل سے اکاؤنٹ پہلے سے موجود ہے' }); + return { token: await newSession(rows[0].id), user: publicUser(rows[0]) }; + }); + + app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signin', async (req, reply) => { + const email = normaliseEmail(req.body?.email), password = String(req.body?.password ?? ''); + if (!signinByIp(ip(req)) || !signinByEmail(email)) return reply.code(429).send({ error: 'بہت زیادہ کوششیں۔ کچھ دیر بعد دوبارہ کوشش کریں۔' }); + 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: 'ای میل یا پاس ورڈ درست نہیں' }); + return { token: await newSession(u.id), user: publicUser(u) }; + }); + + app.get('/api/auth/me', async (req, reply) => { + const u = await sessionUser(req); + return u ? { user: publicUser(u) } : reply.code(401).send({ error: 'signed out' }); + }); + + app.post('/api/auth/signout', async (req) => { + const u = await sessionUser(req); + if (u) await pool.query('DELETE FROM sessions WHERE id = $1', [u.sid]); + return { ok: true }; + }); + + app.post<{ Body: { current?: string; next?: string } }>('/api/auth/password', async (req, reply) => { + const u = await sessionUser(req); + if (!u) return reply.code(401).send({ error: 'دوبارہ لاگ ان کریں' }); + if (!(await verifyPassword(String(req.body?.current ?? ''), u.password_hash))) return reply.code(403).send({ error: 'موجودہ پاس ورڈ درست نہیں' }); + const problem = passwordProblem(req.body?.next); + if (problem) return reply.code(400).send({ error: problem }); + await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [await hashPassword(req.body!.next!), u.id]); + await pool.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [u.id, u.sid]); // sign out other devices + return { ok: true }; + }); + + app.post<{ Body: { password?: string } }>('/api/auth/delete', async (req, reply) => { + const u = await sessionUser(req); + if (!u) return reply.code(401).send({ error: 'دوبارہ لاگ ان کریں' }); + if (!(await verifyPassword(String(req.body?.password ?? ''), u.password_hash))) return reply.code(403).send({ error: 'پاس ورڈ درست نہیں' }); + await pool.query('DELETE FROM users WHERE id = $1', [u.id]); // sessions (and later the reader's library) cascade + return { ok: true }; + }); +} diff --git a/api/src/server.ts b/api/src/server.ts index 669d4557..e7673e10 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -3,11 +3,13 @@ // GET /api/page?url=/p238/... poet, category or poem at that URL // GET /api/search?q=&poet=&page= // GET /api/word?w= Wiktionary meanings and pronunciation (sidebar) +// /api/auth/* accounts (see auth.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'; const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }); const PAGE_SIZE = 20; @@ -133,5 +135,7 @@ app.get<{ Querystring: { w?: string } }>('/api/word', async (req, reply) => { return lookup(w); }); +authRoutes(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 819da114..be0860ff 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -81,3 +81,18 @@ CREATE TABLE IF NOT EXISTS dict_meta ( -- upstream file versions and r name text PRIMARY KEY, value text NOT NULL ); + +-- Accounts (api/src/auth.ts): email address and password only; no email is sent +CREATE TABLE IF NOT EXISTS users ( + id bigserial PRIMARY KEY, + email text NOT NULL UNIQUE, -- stored lowercased + password_hash text NOT NULL, -- scrypt$N$r$p$salt$hash + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS sessions ( + id text PRIMARY KEY, -- SHA-256 of the cookie token (the token itself is never stored) + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id); diff --git a/web/astro.config.mjs b/web/astro.config.mjs index ae7c975f..d7c9147c 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -6,5 +6,11 @@ export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), server: { port: 4200 }, + // Form posts from other sites are refused (CSRF). Astro only trusts the Host header for these + // hostnames, so the production domain must be listed (SITE_HOSTS at build time, comma-separated). + security: { + checkOrigin: true, + allowedDomains: (process.env.SITE_HOSTS ?? '127.0.0.1,localhost').split(',').map((h) => ({ hostname: h.trim() })), + }, i18n: undefined, }); diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 00000000..a4b8d531 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,5 @@ +declare namespace App { + interface Locals { + user: import('./lib/auth').User | null; + } +} diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 6c4bb5f3..b9c576f0 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -41,6 +41,9 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
+ {Astro.locals.user + ? + : }
diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 00000000..2c529830 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,23 @@ +// Site side of accounts: the session token lives in an HTTP-only cookie and is sent to the private API. +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 }; + +// 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 } = {}) { + const res = await fetch(`${API}/api/auth/${path}`, { + method: opts.body || path !== 'me' ? 'POST' : 'GET', + headers: { + ...(opts.body && { 'content-type': 'application/json' }), + ...(opts.token && { authorization: `Bearer ${opts.token}` }), + ...(opts.ip && { 'x-client-ip': opts.ip }), + }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + return { ok: res.ok, status: res.status, data: await res.json().catch(() => ({})) }; +} + +export const setSession = (cookies: AstroCookies, token: string, secure: boolean) => + cookies.set(COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 30 * 86400 }); diff --git a/web/src/middleware.ts b/web/src/middleware.ts new file mode 100644 index 00000000..d2ff4d2d --- /dev/null +++ b/web/src/middleware.ts @@ -0,0 +1,14 @@ +// The signed-in reader (or null) for every page, from the session cookie +import { defineMiddleware } from 'astro:middleware'; +import { auth, COOKIE } from './lib/auth'; + +export const onRequest = defineMiddleware(async (ctx, next) => { + const token = ctx.cookies.get(COOKIE)?.value; + ctx.locals.user = null; + if (token) { + const r = await auth('me', { token }); + if (r.ok) ctx.locals.user = r.data.user; + else if (r.status === 401) ctx.cookies.delete(COOKIE, { path: '/' }); // expired or signed out elsewhere + } + return next(); +}); diff --git a/web/src/pages/account.astro b/web/src/pages/account.astro new file mode 100644 index 00000000..5b389e40 --- /dev/null +++ b/web/src/pages/account.astro @@ -0,0 +1,63 @@ +--- +import Base from '../layouts/Base.astro'; +import { auth, COOKIE } from '../lib/auth'; +import { ud } from '../lib/urdu'; + +const user = Astro.locals.user; +if (!user) return Astro.redirect('/signin?next=/account'); +const token = Astro.cookies.get(COOKIE)!.value; +let error = '', done = ''; +if (Astro.request.method === 'POST') { + const form = await Astro.request.formData(); + const act = form.get('act'); + if (act === 'signout') { + await auth('signout', { token }); + Astro.cookies.delete(COOKIE, { path: '/' }); + return Astro.redirect('/'); + } + if (act === 'password') { + if (form.get('next') !== form.get('next2')) error = 'نئے پاس ورڈ ایک جیسے نہیں'; + else { + const r = await auth('password', { token, body: { current: form.get('current'), next: form.get('next') } }); + r.ok ? (done = 'پاس ورڈ بدل گیا۔ دوسرے آلات سے لاگ آؤٹ کر دیا گیا۔') : (error = r.data.error); + } + } + if (act === 'delete') { + const r = await auth('delete', { token, body: { password: form.get('password') } }); + if (r.ok) { + Astro.cookies.delete(COOKIE, { path: '/' }); + return Astro.redirect('/'); + } + error = r.data.error; + } +} +const since = new Date(user.created_at); +--- + +

میرا اکاؤنٹ

+

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

+ {error && } + {done &&

{done}

} + + + + + + + diff --git a/web/src/pages/signin.astro b/web/src/pages/signin.astro new file mode 100644 index 00000000..e492e80f --- /dev/null +++ b/web/src/pages/signin.astro @@ -0,0 +1,31 @@ +--- +import Base from '../layouts/Base.astro'; +import { auth, setSession } from '../lib/auth'; + +// back to where the reader was (same-site paths only) +const next = (Astro.url.searchParams.get('next') ?? '').startsWith('/') && !(Astro.url.searchParams.get('next') ?? '').startsWith('//') + ? Astro.url.searchParams.get('next')! : '/account'; +if (Astro.locals.user) return Astro.redirect(next); +let error = '', email = ''; +if (Astro.request.method === 'POST') { + const form = await Astro.request.formData(); + email = String(form.get('email') ?? ''); + const r = await auth('signin', { body: { email, password: String(form.get('password') ?? '') }, ip: Astro.clientAddress }); + if (r.ok) { + setSession(Astro.cookies, r.data.token, Astro.url.protocol === 'https:'); + return Astro.redirect(next); + } + error = r.data.error ?? 'کچھ غلط ہو گیا'; +} +--- + +

لاگ ان

+ + diff --git a/web/src/pages/signup.astro b/web/src/pages/signup.astro new file mode 100644 index 00000000..a0f1b560 --- /dev/null +++ b/web/src/pages/signup.astro @@ -0,0 +1,32 @@ +--- +import Base from '../layouts/Base.astro'; +import { auth, setSession } from '../lib/auth'; + +if (Astro.locals.user) return Astro.redirect('/account'); +let error = '', email = ''; +if (Astro.request.method === 'POST') { + const form = await Astro.request.formData(); + email = String(form.get('email') ?? ''); + const password = String(form.get('password') ?? ''); + if (password !== form.get('password2')) error = 'دونوں پاس ورڈ ایک جیسے نہیں'; + else { + const r = await auth('signup', { body: { email, password }, ip: Astro.clientAddress }); + if (r.ok) { + setSession(Astro.cookies, r.data.token, Astro.url.protocol === 'https:'); + return Astro.redirect('/account'); + } + error = r.data.error ?? 'کچھ غلط ہو گیا'; + } +} +--- + +

نیا اکاؤنٹ

+ + diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 98a07d74..b7f517f7 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -166,3 +166,17 @@ h1 + .muted { text-align: center; margin-top: 0; } @media (max-width: 700px) { .dict { top: auto; right: 0; width: auto; max-height: 60vh; border-right: 0; border-top: 1.5px solid var(--border); border-radius: 14px 14px 0 0; } } + +/* accounts */ +.center { text-align: center; } +.account-link { font: inherit; font-size: .9rem; padding: 2px 12px; border: 1.5px solid var(--border); border-radius: 10px; background: var(--inner); color: var(--ink); text-decoration: none; } +.account-form { max-width: 420px; margin: 18px auto; display: flex; flex-direction: column; gap: 10px; } +.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 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); } +.form-error { color: var(--brand); text-align: center; } +.form-done { color: var(--gold); text-align: center; }