diff --git a/bruno/recipe-backend/createUser.bru b/bruno/recipe-backend/createUser.bru index 7762c77..b0fa5e8 100644 --- a/bruno/recipe-backend/createUser.bru +++ b/bruno/recipe-backend/createUser.bru @@ -10,10 +10,14 @@ post { auth: bearer } +auth:bearer { + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc2MzQ5MDcxMywiZXhwIjoxNzYzNTc3MTEzfQ.h8ta-4tVhR7EskZDBLtcFTQ7QllV-PfC09Y0DLjYJa4 +} + body:json { { "userData": { - "userName": "test2", + "userName": "test3", "email": "test@raemer.net" }, "password": "test" diff --git a/bruno/recipe-backend/me.bru b/bruno/recipe-backend/me.bru index 1df9c43..b5fbccf 100644 --- a/bruno/recipe-backend/me.bru +++ b/bruno/recipe-backend/me.bru @@ -11,7 +11,7 @@ get { } auth:bearer { - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjU2NGE5NjY0LTI2ZWYtNGMxMS1hNjIyLWU4MDI2MzczYmRkZCIsImlhdCI6MTc1ODk1MTI4NSwiZXhwIjoxNzU5MDM3Njg1fQ.FeuMlAurJFAhBPyvJFx3MX64WHB0_sa5pldkbQylUOw + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc2MzQ5MDcxMywiZXhwIjoxNzYzNTc3MTEzfQ.h8ta-4tVhR7EskZDBLtcFTQ7QllV-PfC09Y0DLjYJa4 } settings { diff --git a/package.json b/package.json index 26f241a..437aae5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "recipe-backend", "version": "0.0.1", - "description": "Awesome project developed with TypeORM.", + "description": "A backend for managin recipes", "type": "module", "devDependencies": { "@types/node": "^22.13.10", diff --git a/src/apiHelpers/HttpStatusCodes.ts b/src/apiHelpers/HttpStatusCodes.ts new file mode 100644 index 0000000..f8d3a17 --- /dev/null +++ b/src/apiHelpers/HttpStatusCodes.ts @@ -0,0 +1,31 @@ +/** + * HTTP Status Codes + * Comprehensive enum for all standard HTTP status codes + */ +export const enum HttpStatusCode { + // 2xx Success + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NO_CONTENT = 204, + + // 3xx Redirection + MOVED_PERMANENTLY = 301, + FOUND = 302, + NOT_MODIFIED = 304, + + // 4xx Client Errors + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + CONFLICT = 409, + UNPROCESSABLE_ENTITY = 422, + + // 5xx Server Errors + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, +} \ No newline at end of file diff --git a/src/dtos/AuthPayload.ts b/src/dtos/AuthPayload.ts index 7b8c14a..b0ef49b 100644 --- a/src/dtos/AuthPayload.ts +++ b/src/dtos/AuthPayload.ts @@ -6,4 +6,5 @@ import { JwtPayload } from "jsonwebtoken"; */ export interface AuthPayload extends JwtPayload { id: string; + role?: string; // Add role to the JWT payload } diff --git a/src/dtos/UserListResponse.ts b/src/dtos/UserListResponse.ts new file mode 100644 index 0000000..ed6b669 --- /dev/null +++ b/src/dtos/UserListResponse.ts @@ -0,0 +1,8 @@ +import {UserDto} from "./UserDto.js"; + +/** + * API response for delivering a list of users + */ +export class UserListResponse { + valueList: UserDto[] = []; +} \ No newline at end of file diff --git a/src/endpoints/CompactRecipePoint.ts b/src/endpoints/CompactRecipePoint.ts index e75b509..8a822e9 100644 --- a/src/endpoints/CompactRecipePoint.ts +++ b/src/endpoints/CompactRecipePoint.ts @@ -3,6 +3,7 @@ import { asyncHandler } from "../utils/asyncHandler.js"; import { RecipeRepository } from "../repositories/RecipeRepository.js"; import { CompactRecipeHandler } from "../handlers/CompactRecipeHandler.js"; import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityMapper.js"; +import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; /** * Handles all recipe related routes @@ -10,9 +11,7 @@ import { CompactRecipeDtoEntityMapper } from "../mappers/CompactRecipeDtoEntityM const router = Router(); // Inject repo + mapper here -const recipeRepository = new RecipeRepository(); -const compactRecipeMapper = new CompactRecipeDtoEntityMapper(); -const compactRecipeHandler = new CompactRecipeHandler(recipeRepository, compactRecipeMapper); +const compactRecipeHandler = new CompactRecipeHandler(new RecipeRepository(), new CompactRecipeDtoEntityMapper()); /** * Load header data of all recipes * Responds with a list of CompactRecipeDtos @@ -26,7 +25,7 @@ router.get( const searchString : string = req.query.search ? req.query.search.toString().toLowerCase() : ""; console.log("Searching for recipes with title containing", searchString) const response = await compactRecipeHandler.getMatchingRecipes(searchString); - res.status(201).json(response); + res.status(HttpStatusCode.OK).json(response); }) ); diff --git a/src/endpoints/RecipePoint.ts b/src/endpoints/RecipePoint.ts index 50e2bcd..a6a3886 100644 --- a/src/endpoints/RecipePoint.ts +++ b/src/endpoints/RecipePoint.ts @@ -7,6 +7,7 @@ import { RecipeDto } from "../dtos/RecipeDto.js"; import { RecipeIngredientDtoEntityMapper } from "../mappers/RecipeIngredientDtoEntityMapper.js"; import { RecipeIngredientGroupDtoEntityMapper } from "../mappers/RecipeIngredientGroupDtoEntityMapper.js"; import { RecipeInstructionStepDtoEntityMapper } from "../mappers/RecipeInstructionStepDtoEntityMapper.js"; +import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; /** * Handles all recipe related routes @@ -35,7 +36,7 @@ router.post( asyncHandler(async(req, res) => { const requestDto: RecipeDto = req.body; const responseDto = await recipeHandler.createOrUpdateRecipe(requestDto); - res.status(201).json(responseDto); + res.status(HttpStatusCode.CREATED).json(responseDto); }) ) @@ -48,7 +49,7 @@ router.get( asyncHandler(async(req, res) => { const id = req.params.id; const responseDto = await recipeHandler.getRecipeById(id); - res.status(201).json(responseDto); + res.status(HttpStatusCode.OK).json(responseDto); }) ); diff --git a/src/endpoints/UserPoint.ts b/src/endpoints/UserPoint.ts index 4273eb0..72e9f67 100644 --- a/src/endpoints/UserPoint.ts +++ b/src/endpoints/UserPoint.ts @@ -6,11 +6,19 @@ import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import {CreateUserResponse} from "../dtos/CreateUserResponse.js"; import {UserDto} from "../dtos/UserDto.js"; +import {requireAdmin} from "../middleware/authorizationMiddleware.js"; +import {UserListResponse} from "../dtos/UserListResponse.js"; +import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; +export const userBasicRoute = "/user"; /** * Handles all user related routes */ const router = Router(); +router.use((req, res, next) => { + console.log(`Incoming request: ${req.method} ${req.path}`); + next(); +}); // Inject repo + mapper here const handler @@ -42,9 +50,9 @@ router.post( asyncHandler(async (req, res) => { const dto : UserDto = req.body; const response = await handler.updateUserData(dto); - res.status(201).json(response); + res.status(HttpStatusCode.CREATED).json(response); }) -) +); /** * Update password of existing user @@ -56,7 +64,22 @@ router.post( asyncHandler(async (req, res) => { throw Error("not implemented!"); }) -) +); + +/** + * Get all users (admin only) + * Responds with array of UserDto + */ +router.get( + "/all", + //requireAdmin, + asyncHandler(async (req, res) => { + const id = req.currentUser?.id; + const users = await handler.getAllUsers(); + const response : UserListResponse = {valueList: users} + res.status(HttpStatusCode.OK).json(response); + }) +); /** * Get user data for current user @@ -67,9 +90,8 @@ router.get("/me", const id = req.currentUser?.id; if(id){ - // it breaks here because id is no longer a uuid const responseDto = await handler.getUserById(id); - res.status(201).json(responseDto); + res.status(HttpStatusCode.OK).json(responseDto); } }) ); diff --git a/src/errors/httpErrors.ts b/src/errors/httpErrors.ts index 1f0fe4c..43e45d2 100644 --- a/src/errors/httpErrors.ts +++ b/src/errors/httpErrors.ts @@ -1,3 +1,5 @@ +import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; + /** * Base class for all HTTP-related errors. * Extends the built-in Error with a status code and message @@ -17,19 +19,25 @@ export class HttpError extends Error { export class ValidationError extends HttpError { constructor(message: string) { - super(message, 400); // Bad Request + super(message, HttpStatusCode.BAD_REQUEST); // Bad Request } } export class UnauthorizedError extends HttpError { constructor(message: string = "Unauthorized") { - super(message, 401); + super(message, HttpStatusCode.UNAUTHORIZED); } } +export class ForbiddenError extends HttpError { + constructor(message: string = "Forbidden") { + super(message, HttpStatusCode.FORBIDDEN); + } +} + export class NotFoundError extends HttpError { constructor(message: string = "Resource not found") { - super(message, 404); + super(message, HttpStatusCode.NOT_FOUND); } } diff --git a/src/handlers/AuthHandler.ts b/src/handlers/AuthHandler.ts index 3018d09..4f2bffa 100644 --- a/src/handlers/AuthHandler.ts +++ b/src/handlers/AuthHandler.ts @@ -47,7 +47,8 @@ export class AuthHandler { // Create JWT const tokenInfo = encrypt.generateToken({ - id: userId!, // ! to indicate that we've definitely checked for userId being defined + id: userId!, // ! to indicate that we've definitely checked for userId being defined + role: user.role }); const responseDto = new LoginResponse(); diff --git a/src/handlers/UserHandler.ts b/src/handlers/UserHandler.ts index cce1695..7974b21 100644 --- a/src/handlers/UserHandler.ts +++ b/src/handlers/UserHandler.ts @@ -99,4 +99,13 @@ export class UserHandler { } return this.mapper.toDto(userEntity); } + + /** + * Get all users + * @returns Array of UserDto containing all users + */ + async getAllUsers(): Promise { + const userEntities = await this.userRepository.findAll(); + return userEntities.map((entity) => this.mapper.toDto(entity)); + } } diff --git a/src/index.ts b/src/index.ts index 400a346..742fe3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import express, { NextFunction, Request, Response } from "express"; import dotenv from "dotenv"; import { AppDataSource } from "./data-source.js"; import authRoutes, { authBasicRoute } from "./endpoints/AuthPoint.js"; -import userRoutes from "./endpoints/UserPoint.js"; +import userRoutes, {userBasicRoute} from "./endpoints/UserPoint.js"; import compactRecipeRoutes from "./endpoints/CompactRecipePoint.js"; import recipeRoutes from "./endpoints/RecipePoint.js"; import { errorHandler } from "./middleware/errorHandler.js"; @@ -36,7 +36,7 @@ async function startServer() { // Setup routes app.use(authBasicRoute, authRoutes); - app.use("/user", userRoutes); + app.use(userBasicRoute, userRoutes); app.use("/recipe", recipeRoutes); app.use("/compact-recipe", compactRecipeRoutes); @@ -44,10 +44,11 @@ async function startServer() { // must come last! app.use(errorHandler); - console.log("auth and user routes added") + console.log("all routes added") // catch all other routes app.get(/(.*)/, (req: Request, res: Response, next: NextFunction) => { - res.status(400).json({ message: "Bad Request" }); + console.log("unknown route", req.url); + res.status(400).json({ message: "Bad Request - unknown route" }); }); console.log("Routes set up") diff --git a/src/middleware/authenticationMiddleware.ts b/src/middleware/authenticationMiddleware.ts index e460bb8..26cf375 100644 --- a/src/middleware/authenticationMiddleware.ts +++ b/src/middleware/authenticationMiddleware.ts @@ -3,6 +3,7 @@ import jwt from "jsonwebtoken"; import dotenv from "dotenv"; import { authBasicRoute } from "../endpoints/AuthPoint.js"; import { AuthPayload } from "../dtos/AuthPayload.js"; +import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; dotenv.config(); @@ -32,12 +33,12 @@ export const authentication = ( const header = req.headers.authorization; if (!header) { - return res.status(401).json({ message: "Unauthorized" }); + return res.status(HttpStatusCode.UNAUTHORIZED).json({ message: "Unauthorized" }); } const token = header.split(" ")[1]; if (!token) { - return res.status(401).json({ message: "Unauthorized" }); + return res.status(HttpStatusCode.UNAUTHORIZED).json({ message: "Unauthorized" }); } const JWT_SECRET = process.env.JWT_SECRET; @@ -50,6 +51,6 @@ export const authentication = ( req.currentUser = decoded; next(); } catch { - return res.status(401).json({ message: "Unauthorized" }); + return res.status(HttpStatusCode.UNAUTHORIZED).json({ message: "Unauthorized" }); } }; diff --git a/src/middleware/authorizationMiddleware.ts b/src/middleware/authorizationMiddleware.ts index 8b54feb..1247f9d 100644 --- a/src/middleware/authorizationMiddleware.ts +++ b/src/middleware/authorizationMiddleware.ts @@ -1,23 +1,45 @@ -/* import { NextFunction, Request, Response } from "express"; -import { AppDataSource } from "../data-source.js"; -import { UserEntity } from "../entities/UserEntity.js"; +import { NextFunction, Request, Response } from "express"; +import { ForbiddenError } from "../errors/httpErrors.js"; +import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; -// @todo we'll need some other means to determin the user corresponding to the token here as it seems... -export const authorization = (roles: string[]) => { - return async (req: Request, res: Response, next: NextFunction) => { - const userRepo = AppDataSource.getRepository(UserEntity); - const currentUser = req.currentUser; - if(!currentUser){ - return res.status(403).json({ message: "Forbidden - currentUser is missing" }); - } - const userId = currentUser.id - const user = await userRepo.findOne({ - where: { id: req[" currentUser"].id }, - }); - console.log(user); - if (!roles.includes(user.role)) { - return res.status(403).json({ message: "Forbidden" }); - } - next(); - }; -};*/ \ No newline at end of file +/** + * 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 + * @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) => { ... })); + */ +export const requireRole = (allowedRoles: string[]) => { + return (req: Request, res: Response, next: NextFunction) => { + // Check if user is authenticated + if (!req.currentUser) { + return res.status(HttpStatusCode.UNAUTHORIZED).json({ message: "Unauthorized" }); + } + + // Get user's role from the auth payload + const userRole = req.currentUser.role; + console.log("userRole", userRole); + + // Check if user has one of the allowed roles + if (!userRole || !allowedRoles.includes(userRole)) { + throw new ForbiddenError( + `Access denied. Required role: ${allowedRoles.join(" or ")}` + ); + } + + // User has required role, proceed + next(); + }; +}; + +/** + * Convenience middleware for admin-only routes + * + * @example + * router.get("/admin-panel", requireAdmin, asyncHandler(async (req, res) => { ... })); + */ +export const requireAdmin = requireRole(["admin"]); \ No newline at end of file diff --git a/src/middleware/corsMiddleware.ts b/src/middleware/corsMiddleware.ts index 9d05381..e7597a0 100644 --- a/src/middleware/corsMiddleware.ts +++ b/src/middleware/corsMiddleware.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from "express"; +import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; /** * Add CORS header @@ -15,7 +16,7 @@ export function corsHeaders (req: Request, res: Response, next: NextFunction) { // Handle preflight requests quickly if (req.method === 'OPTIONS') { - return res.sendStatus(200); + return res.sendStatus(HttpStatusCode.OK); } next(); diff --git a/src/repositories/UserRepository.ts b/src/repositories/UserRepository.ts index f18c906..e8e07b0 100644 --- a/src/repositories/UserRepository.ts +++ b/src/repositories/UserRepository.ts @@ -9,4 +9,17 @@ export class UserRepository extends AbstractRepository { async findByUserName(userName: string): Promise { return this.repo.findOne({ where: { userName } }); } + + /** + * Find all users + * @returns Array of all UserEntity records + */ + async findAll(): Promise { + return await this.repo.find({ + order: { + lastName: "ASC", + firstName: "ASC", + }, + }); + } }