Harden user management integration
This commit is contained in:
parent
73579d1e79
commit
847f4e9493
18 changed files with 394 additions and 111 deletions
|
|
@ -1,17 +1,24 @@
|
|||
import crypto from "node:crypto";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { users } from "../schema/users.js";
|
||||
import {
|
||||
LastActiveAdminStoreError,
|
||||
UsernameAlreadyExistsStoreError,
|
||||
type CreateUserAccount,
|
||||
type UserStore,
|
||||
} from "../../domain/ports/user.store.js";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
|
||||
export interface CreateUserRow {
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
passwordHash: string;
|
||||
function isUniqueConstraintError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||
);
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
export class UserRepository implements UserStore {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async list() {
|
||||
|
|
@ -41,18 +48,23 @@ export class UserRepository {
|
|||
return row ?? null;
|
||||
}
|
||||
|
||||
async create(input: CreateUserRow) {
|
||||
async create(input: CreateUserAccount) {
|
||||
const db = this.database;
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
username: input.username.trim().toLowerCase(),
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
passwordHash: input.passwordHash,
|
||||
active: true,
|
||||
mustChangePassword: true,
|
||||
});
|
||||
try {
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
username: input.username.trim().toLowerCase(),
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
passwordHash: input.passwordHash,
|
||||
active: true,
|
||||
mustChangePassword: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) throw new UsernameAlreadyExistsStoreError();
|
||||
throw error;
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
|
|
@ -60,16 +72,34 @@ export class UserRepository {
|
|||
userId: string,
|
||||
patch: { role?: UserRole; active?: boolean }
|
||||
) {
|
||||
const db = this.database;
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
...(patch.role !== undefined ? { role: patch.role } : {}),
|
||||
...(patch.active !== undefined ? { active: patch.active } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
return this.findById(userId);
|
||||
return this.database.transaction((tx) => {
|
||||
const [target] = tx.select().from(users).where(eq(users.id, userId)).limit(1).all();
|
||||
if (!target) return null;
|
||||
|
||||
const removesActiveAdmin =
|
||||
target.role === "admin" &&
|
||||
target.active &&
|
||||
(patch.role === "user" || patch.active === false);
|
||||
if (removesActiveAdmin) {
|
||||
const activeAdmins = tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, "admin"), eq(users.active, true)))
|
||||
.limit(2)
|
||||
.all();
|
||||
if (activeAdmins.length <= 1) throw new LastActiveAdminStoreError();
|
||||
}
|
||||
|
||||
tx.update(users)
|
||||
.set({
|
||||
...(patch.role !== undefined ? { role: patch.role } : {}),
|
||||
...(patch.active !== undefined ? { active: patch.active } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.run();
|
||||
return tx.select().from(users).where(eq(users.id, userId)).limit(1).all()[0] ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async setPassword(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
|
||||
export const users = sqliteTable(
|
||||
"users",
|
||||
|
|
@ -8,7 +9,7 @@ export const users = sqliteTable(
|
|||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
name: text("name").notNull(),
|
||||
role: text("role").notNull().default("user"),
|
||||
role: text("role").$type<UserRole>().notNull().default("user"),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
mustChangePassword: integer("must_change_password", { mode: "boolean" })
|
||||
.notNull()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue