Merge pull request 'Accounts: email and password' (#37) from feature/accounts into main
Reviewed-on: #37
This commit is contained in:
commit
6610ed0f71
@ -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
71
api/src/auth.test.ts
Normal 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
131
api/src/auth.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@ -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' });
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
5
web/src/env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user: import('./lib/auth').User | null;
|
||||
}
|
||||
}
|
||||
@ -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
23
web/src/lib/auth.ts
Normal 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
14
web/src/middleware.ts
Normal 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();
|
||||
});
|
||||
63
web/src/pages/account.astro
Normal file
63
web/src/pages/account.astro
Normal 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>
|
||||
31
web/src/pages/signin.astro
Normal file
31
web/src/pages/signin.astro
Normal 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>
|
||||
32
web/src/pages/signup.astro
Normal file
32
web/src/pages/signup.astro
Normal 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>
|
||||
@ -167,6 +167,19 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.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; }
|
||||
/* selection menu (Ganjoor's, by the selected text) */
|
||||
.selmenu { position: absolute; z-index: 30; display: flex; gap: 2px; padding: 4px; border-radius: 10px;
|
||||
background: rgb(14 17 17 / .92); box-shadow: 0 4px 14px rgb(0 0 0 / .25); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user