114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
import { Router, Request } from 'express';
|
|
import { and, asc, eq, ne } from 'drizzle-orm';
|
|
import { db } from '../db/client';
|
|
import { users, authSessions } from '../db/schema';
|
|
import {
|
|
AuthedRequest,
|
|
findUserByUsername,
|
|
hashPassword,
|
|
readSessionToken,
|
|
validateNewCredentials,
|
|
MIN_PASSWORD_LENGTH,
|
|
} from '../auth';
|
|
import { toSnakeCase } from '../utils/case';
|
|
|
|
// User management (memory-bank/FEATURE_User_Accounts.md). Mounted behind
|
|
// requireAuth; everyone-equal trust model — any signed-in user can add users,
|
|
// reset passwords, and remove accounts other than their own. Password hashes
|
|
// never leave the server.
|
|
|
|
const router = Router();
|
|
|
|
function callerId(req: Request): number {
|
|
return (req as AuthedRequest).user!.id;
|
|
}
|
|
|
|
router.get('/', (req, res) => {
|
|
const rows = db
|
|
.select({ id: users.id, username: users.username, createdAt: users.createdAt })
|
|
.from(users)
|
|
.orderBy(asc(users.username))
|
|
.all();
|
|
res.json(rows.map(toSnakeCase));
|
|
});
|
|
|
|
router.post('/', (req, res) => {
|
|
const { username, password } = req.body ?? {};
|
|
const invalid = validateNewCredentials(username, password);
|
|
if (invalid) {
|
|
res.status(400).json({ error: invalid });
|
|
return;
|
|
}
|
|
|
|
// Case-insensitive duplicate guard (the UNIQUE index is case-sensitive).
|
|
if (findUserByUsername((username as string).trim())) {
|
|
res.status(409).json({ error: 'That username is already taken' });
|
|
return;
|
|
}
|
|
|
|
const created = db
|
|
.insert(users)
|
|
.values({ username: (username as string).trim(), passwordHash: hashPassword(password as string) })
|
|
.returning({ id: users.id, username: users.username, createdAt: users.createdAt })
|
|
.get();
|
|
res.status(201).json(toSnakeCase(created));
|
|
});
|
|
|
|
// Password reset — also the "someone forgot theirs" path, so no current-password
|
|
// check (everyone equal). Signs the target out everywhere except the caller's
|
|
// own current session (relevant when resetting your own password).
|
|
router.patch('/:id', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
const target = db.select().from(users).where(eq(users.id, id)).get();
|
|
if (!target) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
const { password } = req.body ?? {};
|
|
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
|
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
|
return;
|
|
}
|
|
|
|
db.update(users).set({ passwordHash: hashPassword(password) }).where(eq(users.id, id)).run();
|
|
|
|
const currentToken = readSessionToken(req);
|
|
db.delete(authSessions)
|
|
.where(
|
|
currentToken
|
|
? and(eq(authSessions.userId, id), ne(authSessions.token, currentToken))
|
|
: eq(authSessions.userId, id)
|
|
)
|
|
.run();
|
|
|
|
res.json({ id, username: target.username });
|
|
});
|
|
|
|
// You can't delete yourself — which also means the last account can never be
|
|
// deleted (you'd have to be signed in as someone else), so no lockout state.
|
|
router.delete('/:id', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
if (id === callerId(req)) {
|
|
res.status(400).json({ error: 'You cannot delete your own account' });
|
|
return;
|
|
}
|
|
|
|
const target = db.select({ id: users.id }).from(users).where(eq(users.id, id)).get();
|
|
if (!target) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
// Explicit session cleanup: better-sqlite3 runs with foreign_keys OFF, so the
|
|
// schema's ON DELETE CASCADE is not enforced at runtime.
|
|
db.transaction((tx) => {
|
|
tx.delete(authSessions).where(eq(authSessions.userId, id)).run();
|
|
tx.delete(users).where(eq(users.id, id)).run();
|
|
});
|
|
|
|
res.status(204).end();
|
|
});
|
|
|
|
export default router;
|