Add login-required user management (admin/user roles, Config->Benutzer)
No self-signup: admins create every account in Config -> Benutzer with a typed start password; a forced password change gates first use. Session- cookie auth protects all /api/* routes; /api/users additionally requires the admin role. See docs/authentication.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b50c00c0ac
commit
c56cfb30ad
36 changed files with 4928 additions and 38 deletions
13
src/db/migrations/0012_lively_turbo.sql
Normal file
13
src/db/migrations/0012_lively_turbo.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`role` text DEFAULT 'user' NOT NULL,
|
||||
`active` integer DEFAULT true NOT NULL,
|
||||
`must_change_password` integer DEFAULT true NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
3017
src/db/migrations/meta/0012_snapshot.json
Normal file
3017
src/db/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,13 @@
|
|||
"when": 1790531351492,
|
||||
"tag": "0011_glossy_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1790617581434,
|
||||
"tag": "0012_lively_turbo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
87
src/db/repositories/user.repository.ts
Normal file
87
src/db/repositories/user.repository.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import crypto from "node:crypto";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { users } from "../schema/users.js";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
|
||||
export interface CreateUserRow {
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async list() {
|
||||
const db = this.database;
|
||||
return db.select().from(users).orderBy(asc(users.name));
|
||||
}
|
||||
|
||||
async count() {
|
||||
const db = this.database;
|
||||
const rows = await db.select({ id: users.id }).from(users).limit(1);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async findById(userId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findByUsername(username: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username.trim().toLowerCase()))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async create(input: CreateUserRow) {
|
||||
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,
|
||||
});
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async updateRoleAndActive(
|
||||
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);
|
||||
}
|
||||
|
||||
async setPassword(
|
||||
userId: string,
|
||||
passwordHash: string,
|
||||
mustChangePassword: boolean
|
||||
) {
|
||||
const db = this.database;
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash, mustChangePassword, updatedAt: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
return this.findById(userId);
|
||||
}
|
||||
}
|
||||
27
src/db/schema/users.ts
Normal file
27
src/db/schema/users.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const users = sqliteTable(
|
||||
"users",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
name: text("name").notNull(),
|
||||
role: text("role").notNull().default("user"),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
mustChangePassword: integer("must_change_password", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => [uniqueIndex("users_username_unique").on(table.username)]
|
||||
);
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
Loading…
Add table
Add a link
Reference in a new issue