MyKindlebox/mykindlebox/server/routes/users.ts

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;