diff --git a/README.md b/README.md index 24171fb..b4b469e 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,5 @@ Before Runningthis on you local machine some things have to be setup DOMAIN: THE DOMAIN NAME OF THE SERVER MAILPASSWORD: THE PASSWORD FOR THE MAIL ACCOUNT DEV: SWITCH FOR DEV MODE AFFECTS EMAIL SERVER + ALLOW_ORIGIN: ORIGIN OF THE PRODUCTION CLIENT (FOR CORS) ``` diff --git a/dev.sh b/dev.sh index 7067090..dcd55c1 100755 --- a/dev.sh +++ b/dev.sh @@ -52,6 +52,7 @@ if [ "$RECREATE" = true ]; then -e DATABASE_URL="postgresql://server:server@postgres:5432/management?schema=public" \ -e NODE_ENV="development" \ -e PORT="${D_PORT}" \ + -e JWT_SECRET="not_for_production" \ -p "${D_PORT}":"${D_PORT}" \ --entrypoint "/app/scripts/docker-entrypoint.dev.sh" \ node diff --git a/package-lock.json b/package-lock.json index fb8ed8f..ec2cb13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@prisma/client": "^3.3.0", + "@types/cors": "^2.8.12", "@types/express-fileupload": "^1.2.1", "@types/handlebars": "^4.1.0", "@types/jsonwebtoken": "^8.5.5", @@ -18,6 +19,7 @@ "@types/nodemailer": "^6.4.4", "@types/redis": "^2.8.32", "argon2": "^0.28.2", + "cors": "^2.8.5", "dotenv": "^10.0.0", "express": "^4.17.1", "express-async-errors": "^3.1.1", @@ -242,6 +244,11 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "node_modules/@types/cors": { + "version": "2.8.12", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", + "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" + }, "node_modules/@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -989,6 +996,18 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -4134,6 +4153,11 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "@types/cors": { + "version": "2.8.12", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", + "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" + }, "@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -4757,6 +4781,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", diff --git a/package.json b/package.json index 9d69e55..c615be5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "homepage": "https://github.com/detleph/server#readme", "dependencies": { "@prisma/client": "^3.3.0", + "@types/cors": "^2.8.12", "@types/handlebars": "^4.1.0", "@types/express-fileupload": "^1.2.1", "@types/jsonwebtoken": "^8.5.5", @@ -28,13 +29,14 @@ "@types/nodemailer": "^6.4.4", "@types/redis": "^2.8.32", "argon2": "^0.28.2", + "cors": "^2.8.5", "dotenv": "^10.0.0", "express": "^4.17.1", - "handlebars": "^4.7.7", "express-async-errors": "^3.1.1", "express-fileupload": "^1.2.1", "file-type": "^16.5.3", "is-svg": "^4.3.2", + "handlebars": "^4.7.7", "jsonwebtoken": "^8.5.1", "mjml": "^4.11.0", "nanoid": "^3.3.3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9d1b73c..79ea5f2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,8 +66,10 @@ model Team { pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String leaderEmail String + verified Boolean @default(false) roles Role[] @relation(name: "participants") + participants Participant[] discipline Discipline @relation(fields: [disciplineId], references: [id], onDelete: Cascade) disciplineId Int } @@ -81,6 +83,8 @@ model Participant { group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) groupId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int roles Role[] } diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts index 760e85d..bb9ed32 100644 --- a/src/Controllers/admin_auth.controller.ts +++ b/src/Controllers/admin_auth.controller.ts @@ -6,7 +6,7 @@ import argon2 from "argon2"; import jwt from "jsonwebtoken"; import { DataType, generateInvalidBodyError } from "./common"; -const JWT_SECRET = process.env.JWT_SECRET || "secret"; +const JWT_SECRET = process.env.JWT_SECRET; const TOKEN_EXPIRY = "4 days"; export interface AuthJWTPayload { @@ -18,6 +18,10 @@ export interface AuthJWTPayload { } function createAdminJWT(admin: Admin & { groups: Group[] }) { + if (!JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } + const payload: AuthJWTPayload = { pid: admin.pid, name: admin.name, diff --git a/src/Controllers/common.ts b/src/Controllers/common.ts index 0b97e88..0f76edc 100644 --- a/src/Controllers/common.ts +++ b/src/Controllers/common.ts @@ -1,6 +1,7 @@ import { AdminLevel, Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; +import { ZodError } from "zod"; import prisma from "../lib/prisma"; interface StringIndexedObject { @@ -23,6 +24,7 @@ export enum DataType { NUMBER = "number", INTEGER = "integer", PERMISSION_LEVEL = "'ELEVATED' | 'STANDARD'", + JOB = "'TEAMLEADER' | 'MEMBER'", DATETIME = "ISOstring", UUID = "string", RESULT_SCHEMA = "result_schema", @@ -32,7 +34,18 @@ interface Body { [k: string]: DataType; } -export function generateInvalidBodyError(body: Body) { +export function generateInvalidBodyError(body: Body, zodError?: ZodError) { + if (zodError instanceof ZodError) { + return { + type: "error", + payload: { + message: "The body of your request did not conform to the requirements", + errors: { body: zodError.format() }, + schema: { body }, + }, + }; + } + return { type: "error", payload: { diff --git a/src/Controllers/discipline.controller.ts b/src/Controllers/discipline.controller.ts index 25bd7ae..bc85a7e 100644 --- a/src/Controllers/discipline.controller.ts +++ b/src/Controllers/discipline.controller.ts @@ -1,6 +1,7 @@ import { Prisma, Organisation, Admin, AdminLevel, Team } from "@prisma/client"; import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; +import { z } from "zod"; import prisma from "../lib/prisma"; import ForwardableError from "../Middleware/error/ForwardableError"; import NotFoundError from "../Middleware/error/NotFoundError"; @@ -16,16 +17,20 @@ import { require("express-async-errors"); -const DisciplineBody = z.object({ - name: z.string(), - maxTeamSize: z.number(), - minTeamSize: z.number(), - briefDescription: z.string(), - fullDescription: z.string(), - eventPid: z.string(), -}) -const UpdateDisciplineBody = DisciplineBody.partial(); +const InitialDisciplineBody = z.object({ + name: z.string().min(1), + minTeamSize: z.number(), + maxTeamSize: z.number(), +}); + +const disciplineRefiner = [ + (args: any) => (args.minTeamSize && args.maxTeamSize ? args.minTeamSize <= args.maxTeamSize : true), + { message: "The minTeamSize must be smaller or equal to the maxTeamSize" }, +] as const; + +const DisciplineBody = InitialDisciplineBody.refine(...disciplineRefiner); +const updateDisciplineBody = InitialDisciplineBody.partial().refine(...disciplineRefiner); const basicDiscipline = { pid: true, @@ -220,37 +225,27 @@ export const createDiscipline = async (req: Request<{ eventPid: string }, {}, Cr return res.status(403).json(createInsufficientPermissionsError()); } - const { name, minTeamSize, maxTeamSize, briefDescription, fullDescription } = req.body; + const result = DisciplineBody.safeParse(req.body); - if (typeof name !== "string" || - typeof minTeamSize !== "number" || - typeof maxTeamSize !== "number" || - typeof briefDescription !== "string" || - (fullDescription && typeof fullDescription !== "string") - ) { + if (result.success === false) { return res.status(400).json( - generateInvalidBodyError({ - name: DataType.STRING, - minTeamSize: DataType.NUMBER, - maxTeamSize: DataType.NUMBER, - }) + generateInvalidBodyError( + { + name: DataType.STRING, + minTeamSize: DataType.NUMBER, + maxTeamSize: DataType.NUMBER, + }, + result.error + ) ); } - if (!validateName(name)) { - return res.status(400).json(NAME_ERROR); - } + const { name, minTeamSize, maxTeamSize } = result.data; try { const discipline = await prisma.discipline.create({ - data: { name, minTeamSize, maxTeamSize, briefDescription, fullDescription, event: { connect: { pid: req.params.eventPid } } }, - select: { - pid: true, - name: true, - minTeamSize: true, - maxTeamSize: true, - event: { select: { pid: true, name: true } }, - }, + data: { name, minTeamSize, maxTeamSize, event: { connect: { pid: req.params.eventPid } } }, + select: basicDiscipline, }); return res.status(201).json({ type: "success", payload: { discipline } }); @@ -262,12 +257,56 @@ export const createDiscipline = async (req: Request<{ eventPid: string }, {}, Cr } }; -interface DeleteDisciplineQueryParams { - pid: string; -} +// requires: auth(ELEVATED) +export const updateDiscipline = async (req: Request<{ pid: string }>, res: Response) => { + if (req.auth?.permission_level !== "ELEVATED") { + res.status(403).json(createInsufficientPermissionsError()); + } + + const result = updateDisciplineBody.safeParse(req.body); // FIXME: Useres can currently use two requests to forgo min/max team size checking altogether + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + name: DataType.STRING, + minTeamSize: DataType.NUMBER, + maxTeamSize: DataType.NUMBER, + }, + result.error + ) + ); + } + + const body = result.data; + const { pid } = req.params; + + try { + const discipline = await prisma.discipline.update({ + where: { pid }, + data: { + name: body.name, + minTeamSize: body.minTeamSize, + maxTeamSize: body.maxTeamSize, + }, + select: basicDiscipline, + }); + + res.status(200).json({ + type: "success", + payload: { discipline }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("discipline", pid); + } + + throw e; + } +}; // requires: auth(ELEVATED) -export const deleteDiscipline = async (req: Request, res: Response) => { +export const deleteDiscipline = async (req: Request<{ pid: string }>, res: Response) => { if (req.auth?.permission_level !== "ELEVATED") { res.status(403).json(createInsufficientPermissionsError()); } diff --git a/src/Controllers/event.controller.ts b/src/Controllers/event.controller.ts index 9480148..1e558c7 100644 --- a/src/Controllers/event.controller.ts +++ b/src/Controllers/event.controller.ts @@ -182,12 +182,15 @@ export const updateEvent = async (req: Request<{ pid: string }>, res: Response) if (result.success === false) { return res.status(400).json( - generateInvalidBodyError({ - name: DataType.STRING, - date: DataType.DATETIME, - briefDescription: DataType.STRING, - ["fullDescription?"]: DataType.STRING, - }) + generateInvalidBodyError( + { + name: DataType.STRING, + date: DataType.DATETIME, + briefDescription: DataType.STRING, + ["fullDescription?"]: DataType.STRING, + }, + result.error + ) ); } @@ -264,7 +267,7 @@ export const deleteEvent = async (req: Request, res: Res return res.status(204).end(); } catch (e) { if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { - return res.status(404).json(generateError(`The organisation with the ID ${pid} could not be found`)); + return res.status(404).json(generateError(`The event with the ID ${pid} could not be found`)); } throw e; diff --git a/src/Controllers/group.controllers.ts b/src/Controllers/group.controllers.ts index ee7fb5e..686ed7a 100644 --- a/src/Controllers/group.controllers.ts +++ b/src/Controllers/group.controllers.ts @@ -1,12 +1,31 @@ -import { Admin } from "@prisma/client"; +import { Admin, Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; +import { z } from "zod"; import prisma from "../lib/prisma"; -import { createInsufficientPermissionsError, generateError, genericError, handleCreateByName } from "./common"; +import { requireResponsibleForGroup } from "../Middleware/auth/auth"; +import NotFoundError from "../Middleware/error/NotFoundError"; +import { + createInsufficientPermissionsError, + DataType, + generateError, + generateInvalidBodyError, + genericError, + handleCreateByName, +} from "./common"; + +const updateGroupBody = z + .object({ + name: z.string().min(1), + user_limit: z.number().int().positive(), + level: z.number().int().nonnegative(), + }) + .partial(); const basicGroup = { pid: true, name: true, + level: true, organisation: { select: { pid: true, name: true } }, } as const; @@ -115,6 +134,52 @@ export const createGroup = async (req: Request<{ organisationPid: string }, {}, ); }; +// requires: auth(STANDARD with GROUP permission) +export const updateGroup = async (req: Request<{ pid: string }>, res: Response) => { + const result = updateGroupBody.safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + name: DataType.STRING, + user_limit: DataType.NUMBER, + level: DataType.NUMBER, + }, + result.error + ) + ); + } + + const body = result.data; + const { pid } = req.params; + + requireResponsibleForGroup(req.auth, pid); + + try { + const group = await prisma.group.update({ + where: { pid }, + data: { + name: body.name, + user_limit: body.user_limit, + level: body.level, + }, + select: basicGroup, + }); + + res.status(200).json({ + type: "success", + payload: { group }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("group", pid); + } + + throw e; + } +}; + interface DeleteGroupQueryParams { pid: string; } @@ -132,7 +197,7 @@ export const deleteGroup = async (req: Request, res: Res return res.status(204).end(); } catch (e) { if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { - return res.status(404).json(generateError(`The group with the ID ${pid} could not be found`)); + throw new NotFoundError("group", pid); } throw e; diff --git a/src/Controllers/participant.controller.ts b/src/Controllers/participant.controller.ts new file mode 100644 index 0000000..cfc6e59 --- /dev/null +++ b/src/Controllers/participant.controller.ts @@ -0,0 +1,163 @@ +import prisma from "../lib/prisma"; +import { z } from "zod"; +import { Request, Response } from "express"; +import { + AUTH_ERROR, + createInsufficientPermissionsError, + DataType, + generateError, + generateInvalidBodyError, +} from "./common"; +import { Job, Prisma } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; +import NotFoundError from "../Middleware/error/NotFoundError"; +import { requireResponsibleForGroup } from "../Middleware/auth/auth"; + +//TODO: add TeamleaderAuthentification + +// REVIEW: All this code should be able to be executed by the teamleader of the team the participant is in AND +// an admin the group of whom overlaps with the team AND an elevated admin + +const ParticipantBody = z.object({ + firstName: z.string(), + lastName: z.string(), + groupId: z.string().uuid(), + //job: z.enum(["TEAMLEADER", "MEMBER"]), +}); + +const returnedParticipant = { + pid: true, + firstName: true, + lastName: true, + relevance: true, + team: { + select: { + pid: true, + name: true, + }, + }, + group: { + select: { + pid: true, + name: true, + }, + }, +} as const; + +// REVIEW: Location of this endpoints (/groups, /teams, /participants, ...?) +export const createParticipant = async (req: Request<{ pid: string }>, res: Response) => { + const result = ParticipantBody.safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + firstname: DataType.STRING, + lastName: DataType.STRING, + groupId: DataType.UUID, + }, + result.error + ) + ); + } + const body = result.data; + const { pid } = req.params; + + /* + if (!req.auth?.isAuthenticated || req.teamleader?.team != pid) { + return res.status(500).json(AUTH_ERROR); + } + if (req.teamleader?.team != pid) { + return res.status(500).json(AUTH_ERROR); + } + requireResponsibleForGroup(req.auth, req.body.groupId); + */ + + try { + const participant = await prisma.participant.create({ + data: { + firstName: body.firstName, + lastName: body.lastName, + relevance: "MEMBER", + group: { connect: { pid: body.groupId } }, + team: { connect: { pid } }, + }, + select: returnedParticipant, + }); + + return res.status(201).json({ + type: "success", + payload: { participant }, + }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + return res + .status(404) + .json(generateError(`Could not link to team with ID '${pid}, or group with ID ${body.groupId}'`)); + } + throw e; + } +}; + +export const updateParticipant = async (req: Request<{ pid: string }>, res: Response) => { + //insert TeamleaderAuth + + const result = ParticipantBody.partial().safeParse(req.body); // Should be partial, right? + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + firstname: DataType.STRING, + lastName: DataType.STRING, + groupId: DataType.UUID, + }, + result.error + ) + ); + } + + const body = result.data; + const { pid } = req.params; + + try { + const participant = await prisma.participant.update({ + where: { pid }, + data: { + firstName: body.firstName, + lastName: body.lastName, + group: { connect: { pid: body.groupId } }, + }, + select: returnedParticipant, + }); + + res.status(200).json({ + type: "success", + payload: { participant }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("participant", pid); + } + + throw e; + } +}; + +export const deleteParticipant = async (req: Request<{ pid: string }>, res: Response) => { + //insert TeamleaderAuth + + const { pid } = req.params; + + try { + await prisma.participant.delete({ where: { pid } }); + + return res.status(204).end(); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + return res.status(404).json(generateError(`The participant with the ID ${pid} could not be found`)); + } + + throw e; + } +}; diff --git a/src/Controllers/role.controller.ts b/src/Controllers/role.controller.ts index e69de29..feff325 100644 --- a/src/Controllers/role.controller.ts +++ b/src/Controllers/role.controller.ts @@ -0,0 +1,159 @@ +import { Prisma, Role } from "@prisma/client"; +import { Request, Response } from "express"; +import { z } from "zod"; +import prisma from "../lib/prisma"; +import { requireLeaderOfTeam, requireResponsibleForParticipant } from "../Middleware/auth/teamleaderAuth"; +import NotFoundError from "../Middleware/error/NotFoundError"; +import { createInsufficientPermissionsError, DataType, generateInvalidBodyError } from "./common"; + +require("express-async-errors"); + +/** + * + * @param teamPid: Pid of the team to add the roles to + * @returns Count of roles added (As roles are helper objects, there should be no need for more) + */ +export async function createRolesForTeam(teamPid: string) { + const schemas = await prisma.roleSchema.findMany({ where: { discipline: { teams: { some: { pid: teamPid } } } } }); + + const teamId = (await prisma.team.findUnique({ where: { pid: teamPid } }))?.id; + + if (!teamId) { + throw new NotFoundError("team", teamPid); + } + + const roles = await prisma.role.createMany({ + data: schemas.map((schema) => ({ schemaId: schema.id, score: "", teamId })), // TODO: Use default score from schema? + }); + + return roles.count; +} + +export async function getRolesForTeam(req: Request<{ teamPid: string }>, res: Response) { + const teamPid = req.params.teamPid; + + requireLeaderOfTeam(req.teamleader, teamPid); + + const roles = await prisma.role.findMany({ + where: { team: { pid: teamPid } }, + select: { + pid: true, + score: true, + schema: { select: { pid: true } }, + participant: { select: { pid: true, firstName: true, lastName: true } }, + }, + }); + + return res.status(200).json({ + type: "success", + payload: { + roles, + }, + }); +} + +const AssignParticipantToRoleBody = z.object({ + participantPid: z.string().uuid(), +}); + +// requires: auth(leader of the team) +export async function assignParticipantToRole(req: Request<{ pid: string }>, res: Response) { + const zBody = AssignParticipantToRoleBody.safeParse(req.body); + + if (zBody.success === false) { + return res.status(400).json(generateInvalidBodyError({ participantPid: DataType.UUID }, zBody.error)); + } + + const { participantPid } = zBody.data; + const rolePid = req.params.pid; + + requireResponsibleForParticipant(req.teamleader, participantPid); + + const schema = await prisma.role.findFirst({ + where: { pid: rolePid, team: { participants: { some: { pid: participantPid } } } }, + select: { participant: { select: { pid: true, firstName: true, lastName: true } } }, + }); + + if (!schema) { + return res.status(404).json({ + type: "error", + payload: { + message: `No role with the ID '${rolePid}' could be found in the scope of the participant with the ID '${participantPid}'`, + }, + }); + } + + // No error handling should be neccesary as the existence of the role and participant have already been checked above + await prisma.role.update({ where: { pid: rolePid }, data: { participant: { connect: { pid: participantPid } } } }); + + return res.status(200).json({ + type: "success", + payload: { + message: `The participant with ID '${participantPid}' was successfully assigned to the role with ID '${rolePid}'`, + ...(schema.participant ? { unassigned: schema.participant } : {}), + }, + }); +} + +export const updateRoleScore = async (req: Request<{ pid: string }, {}, { score: string }>, res: Response) => { + if (req.auth?.permission_level != "ELEVATED") { + res.status(403).json(createInsufficientPermissionsError()); + } + + const { score } = req.body; + + if (typeof score !== "string") { + res.status(400).json(generateInvalidBodyError({ score: DataType.STRING })); + } + + const { pid } = req.params; + + try { + const role = await prisma.role.update({ + where: { pid }, + data: { score }, + select: { + pid: true, + score: true, + schema: { + select: { + pid: true, + name: true, + }, + }, + participant: { + select: { + pid: true, + firstName: true, + lastName: true, + }, + }, + team: { + select: { + pid: true, + name: true, + }, + }, + }, + }); + + res.status(200).json({ + type: "success", + payload: { + role, + }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("role", pid); + } + + throw e; + } +}; + +export async function deleteRolesFromTeam(teamPid: string) { + await prisma.role.deleteMany({ + where: { team: { pid: teamPid } }, + }); +} diff --git a/src/Controllers/role_schema.controller.ts b/src/Controllers/role_schema.controller.ts index 78951d0..8b05caf 100644 --- a/src/Controllers/role_schema.controller.ts +++ b/src/Controllers/role_schema.controller.ts @@ -1,6 +1,7 @@ import { Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; +import { z } from "zod"; import prisma from "../lib/prisma"; import { DurationSchemaT, parseSchema, PointSchemaT } from "../lib/result_schema"; import NotFoundError from "../Middleware/error/NotFoundError"; @@ -8,12 +9,21 @@ import SchemaError from "../Middleware/error/SchemaError"; import { createInsufficientPermissionsError, DataType, + generateError, generateInvalidBodyError, NAME_ERROR, validateName, } from "./common"; +const RoleSchemaBody = z.object({ + name: z.string().min(1), + schema: z.string(), +}); + +const UpdateRoleSchema = RoleSchemaBody.partial(); + const roleSchema = { + pid: true, name: true, schema: true, discipline: { select: { pid: true, name: true } }, diff --git a/src/Controllers/team.controller.ts b/src/Controllers/team.controller.ts new file mode 100644 index 0000000..1f2a5dd --- /dev/null +++ b/src/Controllers/team.controller.ts @@ -0,0 +1,98 @@ +import { Request, Response } from "express"; +import prisma from "../lib/prisma"; +import { createInsufficientPermissionsError, DataType, generateInvalidBodyError } from "./common"; +import { requireLeaderOfTeam } from "../Middleware/auth/teamleaderAuth"; +import { z } from "zod"; + +const TeamBody = z.object({ + teamName: z.string().min(1), + leaderEmail: z.string().email(), + disciplineId: z.string().uuid(), + partFirstName: z.string().min(1), + partLastName: z.string().min(1), + partGroupId: z.string().uuid(), +}); + +interface CreateTeamBody { + teamName: string; + leaderEmail: string; + disciplineId: string; + partFirstName: string; + partLastName: string; + partGroupId: string; +} + +export const getTeams = async (req: Request, res: Response) => { + const teams = prisma.team.findMany({ select: { pid: true, name: true, disciplineId: true } }); + + res.status(200).json(teams); +}; + +export const getTeam = async (req: Request, res: Response) => { + const { pid } = req.params; + + const team = prisma.team.findUnique({ + where: { pid }, + select: { + disciplineId: true, + name: true, + pid: true, + }, + }); + + res.status(200).json(team); +}; + +export const updateTeam = async (req: Request, res: Response) => { + const result = TeamBody.merge(z.object({ pid: z.string().min(1) })) + .omit({ partGroupId: true, partFirstName: true, partLastName: true }) + .safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + teamName: DataType.STRING, + leaderEmail: DataType.STRING, + disciplineId: DataType.UUID, + }, + result.error + ) + ); + } + + const body = result.data; + + try { + requireLeaderOfTeam(req.teamleader, body.pid); + } catch { + return res.status(401).json(createInsufficientPermissionsError("STANDARD")); + } + + const team = prisma.team.update({ + where: { + pid: body.pid, + }, + data: { + name: body.teamName, + discipline: { connect: { pid: body.disciplineId } }, + leaderEmail: body.leaderEmail, + }, + }); + + res.status(204).json(team); +}; + +export const deleteTeam = async (req: Request, res: Response) => { + const { pid } = req.params; + + try { + requireLeaderOfTeam(req.teamleader, pid); + } catch { + return res.status(401).json(createInsufficientPermissionsError("STANDARD")); + } + + prisma.team.delete({ where: { pid } }); + + res.status(204).json("Welp its gone"); +}; diff --git a/src/Controllers/user_auth.controller.ts b/src/Controllers/user_auth.controller.ts index cd8c94a..4205a20 100644 --- a/src/Controllers/user_auth.controller.ts +++ b/src/Controllers/user_auth.controller.ts @@ -3,27 +3,113 @@ import prisma from "../lib/prisma"; import { mailClient } from "../lib/redis"; import { nanoid } from "nanoid"; import { verificationMail } from "../lib/mail"; +import { createInsufficientPermissionsError, DataType, generateInvalidBodyError } from "./common"; +import { generateTeamleaderJWT, requireLeaderOfTeam } from "../Middleware/auth/teamleaderAuth"; +import { createRolesForTeam } from "./role.controller"; +import { any, z } from "zod"; -export const register = async (req: Request, res: Response) => { - //TODO: Implemnt user endpoint and use following code to send verification mail +const TeamBody = z.object({ + teamName: z.string().min(1), + leaderEmail: z.string().email(), + disciplineId: z.string().uuid(), + partFirstName: z.string().min(1), + partLastName: z.string().min(1), + partGroupId: z.string().uuid(), +}); - const user = { - //Supposed to come from database - id: "10", - email: "test@test.com", - }; +interface CreateTeamBody { + teamName: string; + leaderEmail: string; + disciplineId: string; + partFirstName: string; + partLastName: string; + partGroupId: string; +} + +// TODO: Some kind of auth (Teamleader probably) +export const register = async (req: Request<{}, {}, CreateTeamBody>, res: Response) => { + const result = TeamBody.safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + teamName: DataType.STRING, + leaderEmail: DataType.STRING, + disciplineId: DataType.UUID, + partFirstName: DataType.STRING, + partLastName: DataType.STRING, + partGroupId: DataType.UUID, + }, + result.error + ) + ); + } + + const body = result.data; + + const team = await prisma.team.create({ + data: { + leaderEmail: body.leaderEmail, + name: body.teamName, + roles: undefined, + discipline: { connect: { pid: body.disciplineId } }, + participants: { + create: { + firstName: body.partFirstName, + lastName: body.partLastName, + relevance: "TEAMLEADER", + group: { connect: { pid: body.partGroupId } }, + }, + }, + }, + select: { + pid: true, + name: true, + disciplineId: true, + }, + }); + + //TODO: maybe use returned amount of created use? + createRolesForTeam(team.pid); const usid = nanoid(); - (await mailClient).set(usid, user.id); + (await mailClient).set(usid, team.pid); - verificationMail(user.email, "eventname", usid); + verificationMail(req.body.leaderEmail, "eventname", usid); - //Send status code + res.status(201).json({ type: "success", payload: { team } }); +}; + +export const requestToken = async (req: Request, res: Response) => { + const { teamId } = req.body || {}; + + if (!(typeof teamId === "string")) { + return res.status(400).json(generateInvalidBodyError({ teamId: DataType.STRING })); + } + + const team = await prisma.team.findUnique({ + where: { + pid: teamId, + }, + }); + + if (!team) { + return res.status(404).json(); + } + + const usid = nanoid(); + + (await mailClient).set(usid, team.pid); + + verificationMail(team.leaderEmail, "eventname", usid); + + res.status(200).json({ type: "sucess", message: "Email sent!" }); }; export const verifyEmail = async (req: Request, res: Response) => { - const { code } = req.body || {}; + const { code } = req.params || {}; if (!(typeof code === "string")) { return res.status(400).json({ @@ -45,12 +131,23 @@ export const verifyEmail = async (req: Request, res: Response) => { }); } - prisma.participant.update({ + const team = await prisma.team.update({ where: { - id: parseInt(acc), + pid: acc, }, data: { verified: true, }, }); + + mailClient.set(code, ""); + + const token = generateTeamleaderJWT(team); + + res.cookie("teamLeaderToken", token, { + path: "/", + maxAge: 1000 * 60 * 60 * 24 * 4, + }); + + res.status(200).json({ type: "succes", payload: { token } }); //TODO: This needs to set a cookie or smth so that the client also gets this info }; diff --git a/src/Middleware/auth/auth.ts b/src/Middleware/auth/auth.ts index 0b66952..75e20c7 100644 --- a/src/Middleware/auth/auth.ts +++ b/src/Middleware/auth/auth.ts @@ -1,45 +1,96 @@ /// import { NextFunction, Request, Response } from "express"; -import { AuthJWTPayload } from "../../Controllers/admin_auth.controller"; +import { authenticateUser, AuthJWTPayload } from "../../Controllers/admin_auth.controller"; import { authClient } from "../../lib/redis"; import jwt, { JsonWebTokenError, JwtPayload } from "jsonwebtoken"; import prisma from "../../lib/prisma"; +import AuthError from "../error/AuthError"; +import { TeamleaderJWTPayload, _requireTeamleaderAuthentication } from "./teamleaderAuth"; -const JWT_SECRET = process.env.JWT_SECRET || "secret"; +require("express-async-errors"); -const verifyAuthorizationFormat = (authorization: string) => /^Bearer .+$/.test(authorization); +const JWT_SECRET = process.env.JWT_SECRET; -const getBearerToken = (authorization: string) => authorization.slice(7); +export const verifyAuthorizationFormat = (authorization: string) => /^Bearer .+$/.test(authorization); -export const requireAuthentication = async (req: Request, res: Response, next: NextFunction) => { - const { authorization } = req.headers; +export const getBearerToken = (authorization: string) => authorization.slice(7); - if (!authorization) { - return res.status(403).send({ - type: "error", - payload: { - message: "The requeset did not include the Authorization header", - }, - }); - } +const _requireAdminAuthentication = + (config: { optional?: Boolean; controlled?: Boolean } = { optional: false, controlled: false }) => + async (req: Request, res: Response, next: NextFunction) => { + if (!JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } - if (!verifyAuthorizationFormat(authorization)) { - return res.status(400).send({ - type: "error", - payload: { - message: "Malformed Authorization header", - format: "Bearer ", - }, - }); - } + const { authorization } = req.headers; - let token_payload_: string | JwtPayload; + if (!authorization) { + if (config.optional) { + return false; + } - try { - token_payload_ = jwt.verify(getBearerToken(authorization), JWT_SECRET); - } catch (e) { - if (e instanceof JsonWebTokenError) { + return res.status(403).send({ + type: "error", + payload: { + message: "The requeset did not include the Authorization header", + }, + }); + } + + if (!verifyAuthorizationFormat(authorization)) { + return res.status(400).send({ + type: "error", + payload: { + message: "Malformed Authorization header", + format: "Bearer ", + }, + }); + } + + let token_payload_: string | JwtPayload; + + try { + token_payload_ = jwt.verify(getBearerToken(authorization), JWT_SECRET); + } catch (e) { + if (e instanceof JsonWebTokenError) { + return res.status(403).json({ + type: "error", + payload: { + message: "Token could not be verified; It might be expired", + }, + }); + } + + throw e; + } + + const token_payload = token_payload_ as AuthJWTPayload; + + if (!token_payload.permission_level || !token_payload.pid || !token_payload.revision) { + if (typeof (token_payload as unknown as TeamleaderJWTPayload).team === "string") { + return false; + } + + throw new AuthError("The token did not include the required information!"); + } + + const { pid, revision } = token_payload; + + let db_revision = await authClient.get(pid); + + if (db_revision === null) { + // Load the revision ID from the main DB and cache it in redis + const user = await prisma.admin.findUnique({ where: { pid }, select: { revision: true } }); + + if (user) { + db_revision = user.revision.toISOString(); + + await authClient.set(pid, db_revision); + } + } + + if (revision !== db_revision || !revision || !db_revision) { return res.status(403).json({ type: "error", payload: { @@ -48,43 +99,86 @@ export const requireAuthentication = async (req: Request, res: Response, next: N }); } - throw e; - } + req.auth = { + isAuthenticated: true, + pid: token_payload.pid, + name: token_payload.name, + permission_level: token_payload.permission_level, + groups: token_payload.groups, + revision: token_payload.revision, + }; - const token_payload = token_payload_ as AuthJWTPayload; - - const { pid, revision } = token_payload; - - let db_revision = await authClient.get(pid); - - if (db_revision === null) { - // Load the revision ID from the main DB and cache it in redis - const user = await prisma.admin.findUnique({ where: { pid }, select: { revision: true } }); - - if (user) { - db_revision = user.revision.toISOString(); - - await authClient.set(pid, db_revision); + if (!config.controlled) { + next(); } - } - if (revision !== db_revision || !revision || !db_revision) { - return res.status(403).json({ - type: "error", - payload: { - message: "Token could not be verified; It might be expired", - }, - }); - } - - req.auth = { - isAuthenticated: true, - pid: token_payload.pid, - name: token_payload.name, - permission_level: token_payload.permission_level, - groups: token_payload.groups, - revision: token_payload.revision, + return true; }; - next(); -}; +export const requireAuthentication = _requireAdminAuthentication({ optional: false, controlled: false }); + +type AuthType = "admin" | "teamleader"; +interface AuthTypeConfig { + admin?: Boolean; + teamleader?: Boolean; +} + +interface AuthConfiguration { + type: AuthType | AuthTypeConfig; + + optional: Boolean; +} + +function getAuthTypes(type: AuthType | AuthTypeConfig): AuthType[] { + if (typeof type === "string") { + return [type]; + } + + return Object.entries(type) + .filter(([_, value]) => value) + .map(([key, _]) => key as AuthType); +} + +export const requireConfiguredAuthentication = + (config: AuthConfiguration = { optional: false, type: "admin" }) => + async (req: Request, res: Response, next: NextFunction) => { + const types = getAuthTypes(config.type); + const optional = config.optional; + + let adminFinished = false; + let teamleaderFinished = false; + + if (types.includes("admin")) { + adminFinished = Boolean(await _requireAdminAuthentication({ optional: true, controlled: true })(req, res, next)); + + if (adminFinished) { + return next(); + } + } + + if (types.includes("teamleader")) { + teamleaderFinished = Boolean( + _requireTeamleaderAuthentication({ optional: true, controlled: true })(req, res, next) + ); + + if (teamleaderFinished) { + return next(); + } + } + + if (!config.optional) { + throw new AuthError("No sufficient authorization was provided for this operation"); + } + + next(); + }; + +export function requireResponsibleForGroup(auth: AuthJWTPayload | undefined, groupPid: string) { + if (auth?.permission_level === "ELEVATED") { + return; + } + + if (!auth?.groups.includes(groupPid)) { + throw new AuthError("The provided authorization is not valid for the requested operation!"); + } +} diff --git a/src/Middleware/auth/teamleaderAuth.ts b/src/Middleware/auth/teamleaderAuth.ts new file mode 100644 index 0000000..61fc82e --- /dev/null +++ b/src/Middleware/auth/teamleaderAuth.ts @@ -0,0 +1,106 @@ +import { Team } from "@prisma/client"; +import e, { NextFunction, Request, Response } from "express"; +import jwt, { JsonWebTokenError } from "jsonwebtoken"; +import AuthError from "../error/AuthError"; +import { getBearerToken, verifyAuthorizationFormat } from "./auth"; +import prisma from "../../lib/prisma"; + +export interface TeamleaderJWTPayload { + team: string; +} + +const JWT_SECRET = process.env.JWT_SECRET; + +export function generateTeamleaderJWT(teamleader: Team) { + if (!JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } + + const payload: TeamleaderJWTPayload = { + team: teamleader.pid, + }; + + return jwt.sign(payload, JWT_SECRET, { expiresIn: "4 days" }); +} + +export const _requireTeamleaderAuthentication = + (config: { optional: Boolean; controlled: Boolean } = { optional: false, controlled: false }) => + (req: Request, res: Response, next: NextFunction) => { + if (!JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } + + const { authorization } = req.headers; + + if (!authorization) { + if (config.optional) { + return false; + } + + return res.status(403).send({ + type: "error", + payload: { + message: + "The request did not include the Authorization header (Only the team leader can perform this operation)", + }, + }); + } + + if (!verifyAuthorizationFormat(authorization)) { + return res.status(400).send({ + type: "error", + payload: { + message: "Malformed Authorization header", + format: "Bearer ", + }, + }); + } + + try { + const token_payload = jwt.verify(getBearerToken(authorization), JWT_SECRET) as TeamleaderJWTPayload; + + req.teamleader = { + isAuthenticated: true, + team: token_payload.team, + }; + + if (!config.controlled) { + next(); + } + + return true; + } catch (e) { + if (e instanceof JsonWebTokenError) { + return res.status(403).json({ + type: "error", + payload: { + message: "Token could not be verified; It might be expired", + }, + }); + } + + throw e; + } + }; + +export const requireTeamleaderAuthentication = _requireTeamleaderAuthentication({ optional: false, controlled: false }); + +export function requireLeaderOfTeam(auth: TeamleaderJWTPayload | undefined, teamPid: string) { + if (auth?.team !== teamPid) { + throw new AuthError("The provided authorization is not valid for the requested team"); + } +} + +export async function requireResponsibleForParticipant(auth: TeamleaderJWTPayload | undefined, participantPid: string) { + if (!auth) { + throw new AuthError("There was an error with your authorization"); + } + + const teamPid = ( + await prisma.participant.findUnique({ where: { pid: participantPid }, select: { team: { select: { pid: true } } } }) + )?.team.pid; + + if (teamPid !== auth.team) { + throw new AuthError("The provided authorization is not valid for the requested participant"); + } +} diff --git a/src/Middleware/error/AuthError.ts b/src/Middleware/error/AuthError.ts new file mode 100644 index 0000000..0faae4c --- /dev/null +++ b/src/Middleware/error/AuthError.ts @@ -0,0 +1,13 @@ +import ForwardableError from "./ForwardableError"; + +export default class AuthError extends ForwardableError { + protected __oid = "AUTH_ERROR"; + + constructor(message?: string) { + super(403, message ?? "The request did not provide sufficient authentication"); + } + + static isAuthError(err: any): err is AuthError { + return err.__oid === "AUTH_ERROR"; + } +} diff --git a/src/Middleware/error/defaultRoutes.ts b/src/Middleware/error/defaultRoutes.ts new file mode 100644 index 0000000..4a15997 --- /dev/null +++ b/src/Middleware/error/defaultRoutes.ts @@ -0,0 +1,27 @@ +import { Request, Response } from "express"; + +// Only called when no other route matches +export function notFoundHandler(req: Request, res: Response) { + return res.status(404).json({ + type: "error", + payload: { + message: `The ${req.method} HTTP method is implemented for '${req.path}'`, + _links: [ + { + rel: "root", + href: "/api", + }, + ], + }, + }); +} + +export function rootHandler(req: Request, res: Response) { + return res.status(200).json({ + type: "success", + payload: { + message: "Detleph event API", + detail: "This is the API for the Detleph event system", + }, + }); +} diff --git a/src/Routes/discipline.routes.ts b/src/Routes/discipline.routes.ts index 288cc30..6111c64 100644 --- a/src/Routes/discipline.routes.ts +++ b/src/Routes/discipline.routes.ts @@ -5,6 +5,7 @@ import { deleteDiscipline, getAllDisciplines, getDiscipline, + updateDiscipline, } from "../Controllers/discipline.controller"; import { requireAuthentication } from "../Middleware/auth/auth"; @@ -14,6 +15,7 @@ router.get("/", getAllDisciplines); // TODO: Optional auth router.get("/:pid", getDiscipline); +router.patch("/:pid", requireAuthentication, updateDiscipline); router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteDiscipline); eventRouter.post("/:eventPid/disciplines", requireAuthentication, createDiscipline); diff --git a/src/Routes/group.routes.ts b/src/Routes/group.routes.ts index 4ab9387..ae7e470 100644 --- a/src/Routes/group.routes.ts +++ b/src/Routes/group.routes.ts @@ -5,6 +5,7 @@ import { getAllGroups, getAllGroupsWithParam, getGroup, + updateGroup, } from "../Controllers/group.controllers"; import { requireAuthentication } from "../Middleware/auth/auth"; import organisationRouter from "./organisation.routes"; @@ -14,6 +15,7 @@ const router = express.Router(); router.get("/", getAllGroups); router.get("/:pid", getGroup); router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteGroup); +router.patch("/:pid", requireAuthentication, updateGroup); organisationRouter.get("/:organisationPid/groups", getAllGroupsWithParam); organisationRouter.post("/:organisationPid/groups", requireAuthentication, createGroup); diff --git a/src/Routes/participant.routes.ts b/src/Routes/participant.routes.ts new file mode 100644 index 0000000..21937a2 --- /dev/null +++ b/src/Routes/participant.routes.ts @@ -0,0 +1,30 @@ +import express from "express"; +import teamRouter from "./team.routes"; +import { createParticipant, deleteParticipant, updateParticipant } from "../Controllers/participant.controller"; +import { requireAuthentication } from "../Middleware/auth/auth"; +import { requireTeamleaderAuthentication } from "../Middleware/auth/teamleaderAuth"; + +const router = express.Router(); + +teamRouter.post<"/:pid/participant/", { pid: string }>( + "/:pid/participant/", + requireAuthentication, + requireTeamleaderAuthentication, + createParticipant +); + +teamRouter.patch<"/:pid/participant/", { pid: string }>( + "/:pid/participant/", + requireAuthentication, + requireTeamleaderAuthentication, + updateParticipant +); + +teamRouter.delete<"/:pid/participant/", { pid: string }>( + "/:pid/participant/", + requireAuthentication, + requireTeamleaderAuthentication, + deleteParticipant +); + +export default router; diff --git a/src/Routes/role.routes.ts b/src/Routes/role.routes.ts new file mode 100644 index 0000000..5bbd03e --- /dev/null +++ b/src/Routes/role.routes.ts @@ -0,0 +1,9 @@ +import Express from "express"; +import { assignParticipantToRole, getRolesForTeam } from "../Controllers/role.controller"; + +const router = Express.Router(); + +//TO DO: maybe transfer getRolesForTeam to team router -> Seconded +router.get<"team/:teamPid/", { teamPid: string }>("team/:teamPid/", getRolesForTeam); + +router.put<"/:pid/participant", { pid: string }>("/:pid/participant", assignParticipantToRole); diff --git a/src/Routes/team.routes.ts b/src/Routes/team.routes.ts index e69de29..a12afb1 100644 --- a/src/Routes/team.routes.ts +++ b/src/Routes/team.routes.ts @@ -0,0 +1,14 @@ +import express from "express"; +import { requireAuthentication, requireConfiguredAuthentication } from "../Middleware/auth/auth"; +import { requireTeamleaderAuthentication } from "../Middleware/auth/teamleaderAuth"; +import { deleteTeam, getTeam, getTeams, updateTeam } from "../Controllers/team.controller"; + +const router = express.Router(); + +router.get("/", requireConfiguredAuthentication({ type: "admin", optional: false }), getTeams); +router.get("/:id", getTeam); + +router.put("/", requireTeamleaderAuthentication, updateTeam); +router.delete<"/:pid/", { pid: string }>("/:pid/", requireAuthentication, requireTeamleaderAuthentication, deleteTeam); + +export default router; diff --git a/src/Routes/user_auth.routes.ts b/src/Routes/user_auth.routes.ts new file mode 100644 index 0000000..568b764 --- /dev/null +++ b/src/Routes/user_auth.routes.ts @@ -0,0 +1,10 @@ +import express from "express"; +import { register, requestToken, verifyEmail } from "../Controllers/user_auth.controller"; + +const router = express.Router(); + +router.post("/", register); +router.get("/verify/:code", verifyEmail); +router.get("/token", requestToken); + +export default router; diff --git a/src/app.ts b/src/app.ts index aed343f..9121346 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,6 +3,7 @@ import prisma from "./lib/prisma"; import eventRouter from "./Routes/event.routes"; import adminAuthRouter from "./Routes/admin_auth.routes"; import argon2 from "argon2"; +import cors from "cors"; import adminRouter from "./Routes/admin.routes"; import organisationRouter from "./Routes/organisation.routes"; import groupRouter from "./Routes/group.routes"; @@ -12,6 +13,9 @@ import defaultErrorHandler from "./Middleware/error/handler"; import logger from "./Middleware/error/logger"; import debugLogger from "./Middleware/debug/logger"; import mediaRouter from "./Routes/media.routes"; +import userRouter from "./Routes/user_auth.routes"; +import TeamRouter from "./Routes/team.routes"; +import { notFoundHandler, rootHandler } from "./Middleware/error/defaultRoutes"; // Set up async error handling require("express-async-errors"); @@ -20,21 +24,40 @@ require("dotenv").config(); // Load dotenv config const app = express(); -if (process.env.NODE_ENV === "development") { - logger.info("Using development mode"); -} - async function main() { - // Dev - await prisma.admin.upsert({ - where: { id: 1 }, - create: { - name: "admin", - password: await argon2.hash("test", { type: argon2.argon2id }), - permission_level: "ELEVATED", - }, - update: {}, - }); + if (process.env.NODE_ENV === "development") { + logger.info("Using development mode"); + logger.warning( + "This mode should not be used in any production-near environment as it is significantly less secure than the production mode" + ); + + // TODO: How should you login to the prod server by default? Maybe random password? + await prisma.admin.upsert({ + where: { id: 1 }, + create: { + name: "admin", + password: await argon2.hash("test", { type: argon2.argon2id }), + permission_level: "ELEVATED", + }, + update: {}, + }); + + // Allow all CORS requests + app.use(cors()); + } else { + logger.info("Using production mode"); + + // Configure cors + app.use( + cors({ + origin: process.env.ALLOW_ORIGIN, + allowedHeaders: ["Content-Type", "Authorization"], + preflightContinue: false, + methods: ["GET", "PUT", "PATCH", "POST", "DELETE"], + optionsSuccessStatus: 204, + }) + ); + } // Todo: Everything @@ -62,9 +85,18 @@ async function main() { app.use("/api/media", mediaRouter); + app.use("/api/users", userRouter); + + app.use("/api/teams", TeamRouter); + + app.get("/", rootHandler); + app.get("/api", rootHandler); + // Error handling app.use(defaultErrorHandler); // This has to be the LAST ROUTE + app.use(notFoundHandler); + app.listen(process.env.PORT, () => { logger.info(`Listening on port ${process.env.PORT}`); }); diff --git a/src/custom.d.ts b/src/custom.d.ts index bb6296a..86509ba 100644 --- a/src/custom.d.ts +++ b/src/custom.d.ts @@ -1,7 +1,9 @@ import { AuthJWTPayload } from "./Controllers/admin_auth.controller"; +import { TeamleaderJWTPayload } from "./Middleware/auth/teamleaderAuth"; declare module "express-serve-static-core" { interface Request { auth?: AuthJWTPayload & { isAuthenticated: boolean }; + teamleader?: TeamleaderJWTPayload & { isAuthenticated: boolean }; } } diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 4572b1e..c55fd8e 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -5,14 +5,21 @@ import nodemailer from "nodemailer"; import SMTPTransport from "nodemailer/lib/smtp-transport"; import mjml from "./mjml"; -import { randomUUID } from "crypto"; +import logger from "../Middleware/error/logger"; export let mailAccount = { user: process.env.MAILUSER + "@mail." + process.env.DOMAIN, pass: process.env.MAILPASSWORD }; let transporter = - process.env.DEV == "true" || process.env.DOMAIN == undefined + process.env.NODE_ENV == "development" || process.env.DOMAIN == undefined ? (async () => { - mailAccount = await nodemailer.createTestAccount(); + if (process.env.ETHEREAL_EMAIL == undefined || process.env.ETHEREAL_PASSWORD == undefined) { + mailAccount = await nodemailer.createTestAccount(); + } else { + mailAccount = { + user: process.env.ETHEREAL_EMAIL, + pass: process.env.ETHEREAL_PASSWORD, + }; + } if (process.env.NODE_ENV != "test") { console.log(mailAccount); } @@ -43,6 +50,8 @@ let transporter = ); const sendMail = async (from: string, to: string, subject: string, text?: string, html?: string) => { + logger.debug(`Sent email to: ${to}`); + return await ( await transporter ).sendMail({ @@ -57,7 +66,8 @@ const sendMail = async (from: string, to: string, subject: string, text?: string export const verificationMail = async (to: string, eventName: string, verificationLink: string) => { const raw = mjml.getTemplate("emailVerification"); - //TODO: Replace other handlebars with final values + verificationLink = + "https://" + ("api." + process.env.DOMAIN ?? "localhost:3000/api") + "/users/verify/" + verificationLink; const message = Handlebars.compile(raw); const data = { eventName, verificationLink };