diff --git a/src/dtos/AuthPayload.ts b/src/dtos/AuthPayload.ts index b0ef49b..35c801f 100644 --- a/src/dtos/AuthPayload.ts +++ b/src/dtos/AuthPayload.ts @@ -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; +} \ No newline at end of file diff --git a/src/dtos/UserDto.ts b/src/dtos/UserDto.ts index 56fc6c2..cf30ca4 100644 --- a/src/dtos/UserDto.ts +++ b/src/dtos/UserDto.ts @@ -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; } \ No newline at end of file diff --git a/src/entities/UserEntity.ts b/src/entities/UserEntity.ts index f53b82c..1415302 100644 --- a/src/entities/UserEntity.ts +++ b/src/entities/UserEntity.ts @@ -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; +} \ No newline at end of file diff --git a/src/enums/UserRole.ts b/src/enums/UserRole.ts new file mode 100644 index 0000000..8edbfaa --- /dev/null +++ b/src/enums/UserRole.ts @@ -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.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), + })); + }, +}; \ No newline at end of file diff --git a/src/handlers/UserHandler.ts b/src/handlers/UserHandler.ts index a064c03..e51776e 100644 --- a/src/handlers/UserHandler.ts +++ b/src/handlers/UserHandler.ts @@ -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) { diff --git a/src/middleware/authorizationMiddleware.ts b/src/middleware/authorizationMiddleware.ts index 828f217..d2ff8db 100644 --- a/src/middleware/authorizationMiddleware.ts +++ b/src/middleware/authorizationMiddleware.ts @@ -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); \ No newline at end of file diff --git a/src/migrations/1701234567890-ConvertRoleToEnum.ts b/src/migrations/1701234567890-ConvertRoleToEnum.ts new file mode 100644 index 0000000..af59a19 --- /dev/null +++ b/src/migrations/1701234567890-ConvertRoleToEnum.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE "user" + ALTER COLUMN "role" TYPE varchar + USING "role"::text; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS user_role_enum; + `); + } +} \ No newline at end of file