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 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-08 22:53:51 +02:00
parent 3201a1c478
commit e409adc668
14 changed files with 413 additions and 1 deletions

View File

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

71
api/src/auth.test.ts Normal file
View File

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

131
api/src/auth.ts Normal file
View File

@ -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<Buffer>((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<Buffer>((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<string, { n: number; until: number }>();
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 };
});
}

View File

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

View File

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

View File

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

5
web/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare namespace App {
interface Locals {
user: import('./lib/auth').User | null;
}
}

View File

@ -41,6 +41,9 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
</button>
</form>
<div class="toggles">
{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>}
<button type="button" id="font-toggle" aria-label="خط بدلیں">نسخ</button>
<button type="button" id="theme-toggle" aria-label="روشن یا تاریک">◐</button>
</div>

23
web/src/lib/auth.ts Normal file
View File

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

14
web/src/middleware.ts Normal file
View File

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

View File

@ -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);
---
<Base title="میرا اکاؤنٹ">
<h1>میرا اکاؤنٹ</h1>
<p class="muted center"><bdi dir="ltr">{user.email}</bdi> · رکنیت: {ud(since.getFullYear())}</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">
<input type="hidden" name="act" value="signout" />
<button type="submit">لاگ آؤٹ</button>
</form>
<form method="post" class="account-form">
<h2>پاس ورڈ بدلیں</h2>
<input type="hidden" name="act" value="password" />
<label>موجودہ پاس ورڈ<input type="password" name="current" required autocomplete="current-password" dir="ltr" /></label>
<label>نیا پاس ورڈ<input type="password" name="next" required minlength="8" autocomplete="new-password" dir="ltr" /></label>
<label>نیا پاس ورڈ دوبارہ<input type="password" name="next2" required minlength="8" autocomplete="new-password" dir="ltr" /></label>
<button type="submit">پاس ورڈ بدلیں</button>
</form>
<form method="post" class="account-form danger">
<h2>اکاؤنٹ ختم کریں</h2>
<p class="muted">اکاؤنٹ اور اس کا تمام ڈیٹا مستقل طور پر حذف ہو جائے گا۔</p>
<input type="hidden" name="act" value="delete" />
<label>پاس ورڈ<input type="password" name="password" required autocomplete="current-password" dir="ltr" /></label>
<button type="submit">اکاؤنٹ مستقل طور پر ختم کریں</button>
</form>
</Base>

View File

@ -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 ?? 'کچھ غلط ہو گیا';
}
---
<Base title="لاگ ان">
<h1>لاگ ان</h1>
<form method="post" class="account-form">
{error && <p class="form-error" role="alert">{error}</p>}
<label>ای میل<input type="email" name="email" value={email} required autocomplete="email" dir="ltr" /></label>
<label>پاس ورڈ<input type="password" name="password" required autocomplete="current-password" dir="ltr" /></label>
<button type="submit">لاگ ان کریں</button>
<p class="muted">اکاؤنٹ نہیں ہے؟ <a href="/signup">نیا اکاؤنٹ بنائیں</a></p>
<p class="muted note">پاس ورڈ بھول گئے؟ ایڈمن سے رابطہ کریں۔</p>
</form>
</Base>

View File

@ -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 ?? 'کچھ غلط ہو گیا';
}
}
---
<Base title="نیا اکاؤنٹ">
<h1>نیا اکاؤنٹ</h1>
<form method="post" class="account-form">
{error && <p class="form-error" role="alert">{error}</p>}
<label>ای میل<input type="email" name="email" value={email} required autocomplete="email" dir="ltr" /></label>
<label>پاس ورڈ (کم از کم ۸ حروف)<input type="password" name="password" required minlength="8" autocomplete="new-password" dir="ltr" /></label>
<label>پاس ورڈ دوبارہ<input type="password" name="password2" required minlength="8" autocomplete="new-password" dir="ltr" /></label>
<button type="submit">اکاؤنٹ بنائیں</button>
<p class="muted">پہلے سے اکاؤنٹ ہے؟ <a href="/signin">لاگ ان کریں</a></p>
</form>
</Base>

View File

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