Create enum for user role

This commit is contained in:
araemer 2025-11-30 08:41:13 +01:00
parent 8814658142
commit 7ab5923ebb
7 changed files with 152 additions and 37 deletions

View file

@ -1,10 +1,11 @@
import { JwtPayload } from "jsonwebtoken";
import { UserRole } from "../enums/UserRole.js";
/**
* Shape of the payload stored inside JWT tokens.
* We extend JwtPayload so "iat" / "exp" etc. remain available.
*/
export interface AuthPayload extends JwtPayload {
id: string;
role?: string; // Add role to the JWT payload
id: string;
role?: UserRole;
}

View file

@ -1,9 +1,10 @@
import { AbstractDto } from "./AbstractDto.js";
import { UserRole } from "../enums/UserRole.js";
export class UserDto extends AbstractDto {
firstName?: string;
lastName?: string;
userName!: string;
email!: string;
role?: string;
role?: UserRole;
}

View file

@ -1,26 +1,31 @@
import { Entity, Column } from "typeorm";
import { AbstractEntity } from "./AbstractEntity.js";
import { UserRole } from "../enums/UserRole.js";
/**
* Entity describing a user
*/
@Entity({ name: "user" })
export class UserEntity extends AbstractEntity {
@Column({ nullable: false, name: "user_name" })
userName!: string;
@Column({ nullable: false, name: "user_name" })
userName!: string;
@Column({ nullable: false })
email!: string;
@Column({ nullable: false })
email!: string;
@Column({ nullable: false })
password!: string;
@Column({ nullable: false })
password!: string;
@Column({ nullable: true, name: "first_name"})
firstName?: string;
@Column({ nullable: true, name: "first_name" })
firstName?: string;
@Column({ nullable: true, name: "last_name"})
lastName?: string;
@Column({ nullable: true, name: "last_name" })
lastName?: string;
@Column({ default: "user" })
role!: string;
@Column({
type: "enum",
enum: UserRole,
default: UserRole.USER,
})
role!: UserRole;
}

60
src/enums/UserRole.ts Normal file
View file

@ -0,0 +1,60 @@
/**
* User roles enum
* Used across backend, API, and frontend
* We need to use a plain enum here for typeORM which is why the enum may differ from the frontend implementation
* However, we must ensure that the content of both enums is identical!
*/
export enum UserRole {
USER = "user",
ADMIN = "admin",
}
/**
* Helper functions for UserRole
*/
export const UserRoleHelper = {
/**
* Get all role values as array
*/
getAllRoles(): UserRole[] {
return Object.values(UserRole);
},
/**
* Check if a string is a valid role
*/
isValidRole(value: string): value is UserRole {
return Object.values(UserRole).includes(value as UserRole);
},
/**
* Get role from string with validation
*/
fromString(value: string): UserRole {
if (this.isValidRole(value)) {
return value as UserRole;
}
throw new Error(`Invalid role: ${value}`);
},
/**
* Get display name for role (German)
*/
getDisplayName(role: UserRole): string {
const displayNames: Record<UserRole, string> = {
[UserRole.USER]: "Benutzer",
[UserRole.ADMIN]: "Administrator",
};
return displayNames[role];
},
/**
* Get all roles with display names for dropdowns
*/
getRoleOptions(): Array<{ value: UserRole; label: string }> {
return this.getAllRoles().map((role) => ({
value: role,
label: this.getDisplayName(role),
}));
},
};

View file

@ -7,6 +7,7 @@ import {UserDtoEntityMapper} from "../mappers/UserDtoEntityMapper.js";
import {UUID} from "crypto";
import {ChangeUserPasswordRequest} from "../dtos/ChangeUserPasswordRequest.js";
import {UserEntity} from "../entities/UserEntity.js";
import {UserRole, UserRoleHelper} from "../enums/UserRole.js";
/**
* Controls all user specific actions
@ -34,13 +35,22 @@ export class UserHandler {
throw new ValidationError("Password is required");
}
// user name must be unique
const existingUser = await this.userRepository.findByUserName(userName);
if (existingUser) {
throw new ConflictError("User with this user name already exists");
}
// Validate role if provided
if (requestData.userData.role && !UserRoleHelper.isValidRole(requestData.userData.role)) {
throw new ValidationError(`Invalid role: ${requestData.userData.role}`);
}
// Set default role if not provided
if (!requestData.userData.role) {
requestData.userData.role = UserRole.USER;
}
const userEntity = this.mapper.toEntity(requestData.userData);
userEntity.password = await encrypt.encryptpass(password);
@ -72,6 +82,12 @@ export class UserHandler {
// user does not exist yet -> Wrong method is used
throw new ValidationError("Cannot save user without valid userId. Use user/create to create a new user");
}
// Validate role if provided
if (dto.role && !UserRoleHelper.isValidRole(dto.role)) {
throw new ValidationError(`Invalid role: ${dto.role}`);
}
// First: Load current version of user from database
const entity = await this.userRepository.findById(userId);
if (!entity) {

View file

@ -1,19 +1,20 @@
import { NextFunction, Request, Response } from "express";
import { ForbiddenError } from "../errors/httpErrors.js";
import { HttpStatusCode } from "../apiHelpers/HttpStatusCodes.js";
import { UserRole } from "../enums/UserRole.js";
/**
* Middleware to check if the current user has one of the required roles
* Must be used after the authentication middleware
*
* @param allowedRoles Array of role names that are allowed to access the route
* @param allowedRoles Array of roles that are allowed to access the route
* @returns Express middleware function
*
* @example
* router.get("/admin-only", requireRole(["admin"]), asyncHandler(async (req, res) => { ... }));
* router.post("/admin-or-moderator", requireRole(["admin", "moderator"]), asyncHandler(async (req, res) => { ... }));
* router.get("/admin-only", requireRole([UserRole.ADMIN]), asyncHandler(async (req, res) => { ... }));
* router.post("/admin-or-moderator", requireRole([UserRole.ADMIN, UserRole.MODERATOR]), asyncHandler(async (req, res) => { ... }));
*/
export const requireRole = (allowedRoles: string[]) => {
export const requireRole = (allowedRoles: UserRole[]) => {
return (req: Request, res: Response, next: NextFunction) => {
// Check if user is authenticated
if (!req.currentUser) {
@ -41,7 +42,7 @@ export const requireRole = (allowedRoles: string[]) => {
* @example
* router.get("/admin-panel", requireAdmin, asyncHandler(async (req, res) => { ... }));
*/
export const requireAdmin = requireRole(["admin"]);
export const requireAdmin = requireRole([UserRole.ADMIN]);
/**
* Middleware to check if user is either an admin or the owner of the resource
@ -51,14 +52,7 @@ export const requireAdmin = requireRole(["admin"]);
* @returns Express middleware function
*
* @example
* // For route params: /user/:userId
* router.put("/:userId", requireAdminOrOwner(req => req.params.userId), asyncHandler(...));
*
* // For request body: { userId: "123" }
* router.post("/update", requireAdminOrOwner(req => req.body.userId), asyncHandler(...));
*
* // For query params: /user?userId=123
* router.get("/profile", requireAdminOrOwner(req => req.query.userId), asyncHandler(...));
*/
export const requireAdminOrOwner = (
getUserIdFromRequest: (req: Request) => string | undefined
@ -74,7 +68,7 @@ export const requireAdminOrOwner = (
const targetUserId = getUserIdFromRequest(req);
// Allow if user is admin
if (currentUserRole === "admin") {
if (currentUserRole === UserRole.ADMIN) {
return next();
}
@ -93,11 +87,5 @@ export const requireAdminOrOwner = (
/**
* Middleware for routes where the user ID is in the request body
* Common for update operations
*
* @example
* router.post("/update", requireAdminOrSelf, asyncHandler(async (req, res) => {
* const dto: UserDto = req.body;
* // dto.id is checked against current user
* }));
*/
export const requireAdminOrSelf = requireAdminOrOwner(req => req.body.id || req.body.userId);

View file

@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Migration to convert role column from varchar to enum
*
* File name should be: TIMESTAMP-ConvertRoleToEnum.ts
* Example: 1701234567890-ConvertRoleToEnum.ts
*
* Place in: src/migrations/
*/
export class ConvertRoleToEnum1701234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE user_role_enum AS ENUM ('user', 'admin');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
`);
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" TYPE user_role_enum
USING "role"::user_role_enum;
`);
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" SET DEFAULT 'user';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "user"
ALTER COLUMN "role" TYPE varchar
USING "role"::text;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS user_role_enum;
`);
}
}