Create enum for user role
This commit is contained in:
parent
8814658142
commit
7ab5923ebb
7 changed files with 152 additions and 37 deletions
|
|
@ -1,10 +1,11 @@
|
||||||
import { JwtPayload } from "jsonwebtoken";
|
import { JwtPayload } from "jsonwebtoken";
|
||||||
|
import { UserRole } from "../enums/UserRole.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape of the payload stored inside JWT tokens.
|
* Shape of the payload stored inside JWT tokens.
|
||||||
* We extend JwtPayload so "iat" / "exp" etc. remain available.
|
* We extend JwtPayload so "iat" / "exp" etc. remain available.
|
||||||
*/
|
*/
|
||||||
export interface AuthPayload extends JwtPayload {
|
export interface AuthPayload extends JwtPayload {
|
||||||
id: string;
|
id: string;
|
||||||
role?: string; // Add role to the JWT payload
|
role?: UserRole;
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { AbstractDto } from "./AbstractDto.js";
|
import { AbstractDto } from "./AbstractDto.js";
|
||||||
|
import { UserRole } from "../enums/UserRole.js";
|
||||||
|
|
||||||
export class UserDto extends AbstractDto {
|
export class UserDto extends AbstractDto {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
userName!: string;
|
userName!: string;
|
||||||
email!: string;
|
email!: string;
|
||||||
role?: string;
|
role?: UserRole;
|
||||||
}
|
}
|
||||||
|
|
@ -1,26 +1,31 @@
|
||||||
import { Entity, Column } from "typeorm";
|
import { Entity, Column } from "typeorm";
|
||||||
import { AbstractEntity } from "./AbstractEntity.js";
|
import { AbstractEntity } from "./AbstractEntity.js";
|
||||||
|
import { UserRole } from "../enums/UserRole.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity describing a user
|
* Entity describing a user
|
||||||
*/
|
*/
|
||||||
@Entity({ name: "user" })
|
@Entity({ name: "user" })
|
||||||
export class UserEntity extends AbstractEntity {
|
export class UserEntity extends AbstractEntity {
|
||||||
@Column({ nullable: false, name: "user_name" })
|
@Column({ nullable: false, name: "user_name" })
|
||||||
userName!: string;
|
userName!: string;
|
||||||
|
|
||||||
@Column({ nullable: false })
|
@Column({ nullable: false })
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
@Column({ nullable: false })
|
@Column({ nullable: false })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
@Column({ nullable: true, name: "first_name"})
|
@Column({ nullable: true, name: "first_name" })
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
|
|
||||||
@Column({ nullable: true, name: "last_name"})
|
@Column({ nullable: true, name: "last_name" })
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
@Column({ default: "user" })
|
@Column({
|
||||||
role!: string;
|
type: "enum",
|
||||||
}
|
enum: UserRole,
|
||||||
|
default: UserRole.USER,
|
||||||
|
})
|
||||||
|
role!: UserRole;
|
||||||
|
}
|
||||||
60
src/enums/UserRole.ts
Normal file
60
src/enums/UserRole.ts
Normal 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),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@ import {UserDtoEntityMapper} from "../mappers/UserDtoEntityMapper.js";
|
||||||
import {UUID} from "crypto";
|
import {UUID} from "crypto";
|
||||||
import {ChangeUserPasswordRequest} from "../dtos/ChangeUserPasswordRequest.js";
|
import {ChangeUserPasswordRequest} from "../dtos/ChangeUserPasswordRequest.js";
|
||||||
import {UserEntity} from "../entities/UserEntity.js";
|
import {UserEntity} from "../entities/UserEntity.js";
|
||||||
|
import {UserRole, UserRoleHelper} from "../enums/UserRole.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controls all user specific actions
|
* Controls all user specific actions
|
||||||
|
|
@ -34,13 +35,22 @@ export class UserHandler {
|
||||||
throw new ValidationError("Password is required");
|
throw new ValidationError("Password is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// user name must be unique
|
// user name must be unique
|
||||||
const existingUser = await this.userRepository.findByUserName(userName);
|
const existingUser = await this.userRepository.findByUserName(userName);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new ConflictError("User with this user name already exists");
|
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);
|
const userEntity = this.mapper.toEntity(requestData.userData);
|
||||||
userEntity.password = await encrypt.encryptpass(password);
|
userEntity.password = await encrypt.encryptpass(password);
|
||||||
|
|
||||||
|
|
@ -72,6 +82,12 @@ export class UserHandler {
|
||||||
// user does not exist yet -> Wrong method is used
|
// 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");
|
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
|
// First: Load current version of user from database
|
||||||
const entity = await this.userRepository.findById(userId);
|
const entity = await this.userRepository.findById(userId);
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { ForbiddenError } from "../errors/httpErrors.js";
|
import { ForbiddenError } from "../errors/httpErrors.js";
|
||||||
import { HttpStatusCode } from "../apiHelpers/HttpStatusCodes.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
|
* Middleware to check if the current user has one of the required roles
|
||||||
* Must be used after the authentication middleware
|
* 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
|
* @returns Express middleware function
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* router.get("/admin-only", requireRole(["admin"]), asyncHandler(async (req, res) => { ... }));
|
* router.get("/admin-only", requireRole([UserRole.ADMIN]), asyncHandler(async (req, res) => { ... }));
|
||||||
* router.post("/admin-or-moderator", requireRole(["admin", "moderator"]), 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) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
if (!req.currentUser) {
|
if (!req.currentUser) {
|
||||||
|
|
@ -41,7 +42,7 @@ export const requireRole = (allowedRoles: string[]) => {
|
||||||
* @example
|
* @example
|
||||||
* router.get("/admin-panel", requireAdmin, asyncHandler(async (req, res) => { ... }));
|
* 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
|
* 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
|
* @returns Express middleware function
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // For route params: /user/:userId
|
|
||||||
* router.put("/:userId", requireAdminOrOwner(req => req.params.userId), asyncHandler(...));
|
* 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 = (
|
export const requireAdminOrOwner = (
|
||||||
getUserIdFromRequest: (req: Request) => string | undefined
|
getUserIdFromRequest: (req: Request) => string | undefined
|
||||||
|
|
@ -74,7 +68,7 @@ export const requireAdminOrOwner = (
|
||||||
const targetUserId = getUserIdFromRequest(req);
|
const targetUserId = getUserIdFromRequest(req);
|
||||||
|
|
||||||
// Allow if user is admin
|
// Allow if user is admin
|
||||||
if (currentUserRole === "admin") {
|
if (currentUserRole === UserRole.ADMIN) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,11 +87,5 @@ export const requireAdminOrOwner = (
|
||||||
/**
|
/**
|
||||||
* Middleware for routes where the user ID is in the request body
|
* Middleware for routes where the user ID is in the request body
|
||||||
* Common for update operations
|
* 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);
|
export const requireAdminOrSelf = requireAdminOrOwner(req => req.body.id || req.body.userId);
|
||||||
44
src/migrations/1701234567890-ConvertRoleToEnum.ts
Normal file
44
src/migrations/1701234567890-ConvertRoleToEnum.ts
Normal 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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue