Merge branch 'feature-roles' into feature-endpoints

This commit is contained in:
La_Felx
2022-05-31 06:57:16 +02:00
committed by GitHub
28 changed files with 1186 additions and 143 deletions
+1
View File
@@ -14,4 +14,5 @@ Before Runningthis on you local machine some things have to be setup
DOMAIN: THE DOMAIN NAME OF THE SERVER DOMAIN: THE DOMAIN NAME OF THE SERVER
MAILPASSWORD: THE PASSWORD FOR THE MAIL ACCOUNT MAILPASSWORD: THE PASSWORD FOR THE MAIL ACCOUNT
DEV: SWITCH FOR DEV MODE AFFECTS EMAIL SERVER DEV: SWITCH FOR DEV MODE AFFECTS EMAIL SERVER
ALLOW_ORIGIN: ORIGIN OF THE PRODUCTION CLIENT (FOR CORS)
``` ```
+1
View File
@@ -52,6 +52,7 @@ if [ "$RECREATE" = true ]; then
-e DATABASE_URL="postgresql://server:server@postgres:5432/management?schema=public" \ -e DATABASE_URL="postgresql://server:server@postgres:5432/management?schema=public" \
-e NODE_ENV="development" \ -e NODE_ENV="development" \
-e PORT="${D_PORT}" \ -e PORT="${D_PORT}" \
-e JWT_SECRET="not_for_production" \
-p "${D_PORT}":"${D_PORT}" \ -p "${D_PORT}":"${D_PORT}" \
--entrypoint "/app/scripts/docker-entrypoint.dev.sh" \ --entrypoint "/app/scripts/docker-entrypoint.dev.sh" \
node node
+33
View File
@@ -10,6 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@prisma/client": "^3.3.0", "@prisma/client": "^3.3.0",
"@types/cors": "^2.8.12",
"@types/express-fileupload": "^1.2.1", "@types/express-fileupload": "^1.2.1",
"@types/handlebars": "^4.1.0", "@types/handlebars": "^4.1.0",
"@types/jsonwebtoken": "^8.5.5", "@types/jsonwebtoken": "^8.5.5",
@@ -18,6 +19,7 @@
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/redis": "^2.8.32", "@types/redis": "^2.8.32",
"argon2": "^0.28.2", "argon2": "^0.28.2",
"cors": "^2.8.5",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"express": "^4.17.1", "express": "^4.17.1",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
@@ -242,6 +244,11 @@
"integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==",
"dev": true "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": { "node_modules/@types/express": {
"version": "4.17.13", "version": "4.17.13",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
@@ -989,6 +996,18 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true "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": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -4134,6 +4153,11 @@
"integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==",
"dev": true "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": { "@types/express": {
"version": "4.17.13", "version": "4.17.13",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
@@ -4757,6 +4781,15 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true "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": { "create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+3 -1
View File
@@ -20,6 +20,7 @@
"homepage": "https://github.com/detleph/server#readme", "homepage": "https://github.com/detleph/server#readme",
"dependencies": { "dependencies": {
"@prisma/client": "^3.3.0", "@prisma/client": "^3.3.0",
"@types/cors": "^2.8.12",
"@types/handlebars": "^4.1.0", "@types/handlebars": "^4.1.0",
"@types/express-fileupload": "^1.2.1", "@types/express-fileupload": "^1.2.1",
"@types/jsonwebtoken": "^8.5.5", "@types/jsonwebtoken": "^8.5.5",
@@ -28,13 +29,14 @@
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/redis": "^2.8.32", "@types/redis": "^2.8.32",
"argon2": "^0.28.2", "argon2": "^0.28.2",
"cors": "^2.8.5",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"express": "^4.17.1", "express": "^4.17.1",
"handlebars": "^4.7.7",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"express-fileupload": "^1.2.1", "express-fileupload": "^1.2.1",
"file-type": "^16.5.3", "file-type": "^16.5.3",
"is-svg": "^4.3.2", "is-svg": "^4.3.2",
"handlebars": "^4.7.7",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"mjml": "^4.11.0", "mjml": "^4.11.0",
"nanoid": "^3.3.3", "nanoid": "^3.3.3",
+4
View File
@@ -66,8 +66,10 @@ model Team {
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String name String
leaderEmail String leaderEmail String
verified Boolean @default(false)
roles Role[] @relation(name: "participants") roles Role[] @relation(name: "participants")
participants Participant[]
discipline Discipline @relation(fields: [disciplineId], references: [id], onDelete: Cascade) discipline Discipline @relation(fields: [disciplineId], references: [id], onDelete: Cascade)
disciplineId Int disciplineId Int
} }
@@ -81,6 +83,8 @@ model Participant {
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
groupId Int groupId Int
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int
roles Role[] roles Role[]
} }
+5 -1
View File
@@ -6,7 +6,7 @@ import argon2 from "argon2";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { DataType, generateInvalidBodyError } from "./common"; 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"; const TOKEN_EXPIRY = "4 days";
export interface AuthJWTPayload { export interface AuthJWTPayload {
@@ -18,6 +18,10 @@ export interface AuthJWTPayload {
} }
function createAdminJWT(admin: Admin & { groups: Group[] }) { function createAdminJWT(admin: Admin & { groups: Group[] }) {
if (!JWT_SECRET) {
throw new Error("JWT_SECRET not set");
}
const payload: AuthJWTPayload = { const payload: AuthJWTPayload = {
pid: admin.pid, pid: admin.pid,
name: admin.name, name: admin.name,
+14 -1
View File
@@ -1,6 +1,7 @@
import { AdminLevel, Prisma } from "@prisma/client"; import { AdminLevel, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { ZodError } from "zod";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
interface StringIndexedObject { interface StringIndexedObject {
@@ -23,6 +24,7 @@ export enum DataType {
NUMBER = "number", NUMBER = "number",
INTEGER = "integer", INTEGER = "integer",
PERMISSION_LEVEL = "'ELEVATED' | 'STANDARD'", PERMISSION_LEVEL = "'ELEVATED' | 'STANDARD'",
JOB = "'TEAMLEADER' | 'MEMBER'",
DATETIME = "ISOstring", DATETIME = "ISOstring",
UUID = "string", UUID = "string",
RESULT_SCHEMA = "result_schema", RESULT_SCHEMA = "result_schema",
@@ -32,7 +34,18 @@ interface Body {
[k: string]: DataType; [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 { return {
type: "error", type: "error",
payload: { payload: {
+75 -36
View File
@@ -1,6 +1,7 @@
import { Prisma, Organisation, Admin, AdminLevel, Team } from "@prisma/client"; import { Prisma, Organisation, Admin, AdminLevel, Team } from "@prisma/client";
import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { z } from "zod";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import ForwardableError from "../Middleware/error/ForwardableError"; import ForwardableError from "../Middleware/error/ForwardableError";
import NotFoundError from "../Middleware/error/NotFoundError"; import NotFoundError from "../Middleware/error/NotFoundError";
@@ -16,16 +17,20 @@ import {
require("express-async-errors"); 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 = { const basicDiscipline = {
pid: true, pid: true,
@@ -220,37 +225,27 @@ export const createDiscipline = async (req: Request<{ eventPid: string }, {}, Cr
return res.status(403).json(createInsufficientPermissionsError()); return res.status(403).json(createInsufficientPermissionsError());
} }
const { name, minTeamSize, maxTeamSize, briefDescription, fullDescription } = req.body; const result = DisciplineBody.safeParse(req.body);
if (typeof name !== "string" || if (result.success === false) {
typeof minTeamSize !== "number" ||
typeof maxTeamSize !== "number" ||
typeof briefDescription !== "string" ||
(fullDescription && typeof fullDescription !== "string")
) {
return res.status(400).json( return res.status(400).json(
generateInvalidBodyError({ generateInvalidBodyError(
name: DataType.STRING, {
minTeamSize: DataType.NUMBER, name: DataType.STRING,
maxTeamSize: DataType.NUMBER, minTeamSize: DataType.NUMBER,
}) maxTeamSize: DataType.NUMBER,
},
result.error
)
); );
} }
if (!validateName(name)) { const { name, minTeamSize, maxTeamSize } = result.data;
return res.status(400).json(NAME_ERROR);
}
try { try {
const discipline = await prisma.discipline.create({ const discipline = await prisma.discipline.create({
data: { name, minTeamSize, maxTeamSize, briefDescription, fullDescription, event: { connect: { pid: req.params.eventPid } } }, data: { name, minTeamSize, maxTeamSize, event: { connect: { pid: req.params.eventPid } } },
select: { select: basicDiscipline,
pid: true,
name: true,
minTeamSize: true,
maxTeamSize: true,
event: { select: { pid: true, name: true } },
},
}); });
return res.status(201).json({ type: "success", payload: { discipline } }); return res.status(201).json({ type: "success", payload: { discipline } });
@@ -262,12 +257,56 @@ export const createDiscipline = async (req: Request<{ eventPid: string }, {}, Cr
} }
}; };
interface DeleteDisciplineQueryParams { // requires: auth(ELEVATED)
pid: string; 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) // requires: auth(ELEVATED)
export const deleteDiscipline = async (req: Request<DeleteDisciplineQueryParams>, res: Response) => { export const deleteDiscipline = async (req: Request<{ pid: string }>, res: Response) => {
if (req.auth?.permission_level !== "ELEVATED") { if (req.auth?.permission_level !== "ELEVATED") {
res.status(403).json(createInsufficientPermissionsError()); res.status(403).json(createInsufficientPermissionsError());
} }
+10 -7
View File
@@ -182,12 +182,15 @@ export const updateEvent = async (req: Request<{ pid: string }>, res: Response)
if (result.success === false) { if (result.success === false) {
return res.status(400).json( return res.status(400).json(
generateInvalidBodyError({ generateInvalidBodyError(
name: DataType.STRING, {
date: DataType.DATETIME, name: DataType.STRING,
briefDescription: DataType.STRING, date: DataType.DATETIME,
["fullDescription?"]: DataType.STRING, briefDescription: DataType.STRING,
}) ["fullDescription?"]: DataType.STRING,
},
result.error
)
); );
} }
@@ -264,7 +267,7 @@ export const deleteEvent = async (req: Request<DeleteEventQueryParams>, res: Res
return res.status(204).end(); return res.status(204).end();
} catch (e) { } catch (e) {
if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { 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; throw e;
+68 -3
View File
@@ -1,12 +1,31 @@
import { Admin } from "@prisma/client"; import { Admin, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { z } from "zod";
import prisma from "../lib/prisma"; 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 = { const basicGroup = {
pid: true, pid: true,
name: true, name: true,
level: true,
organisation: { select: { pid: true, name: true } }, organisation: { select: { pid: true, name: true } },
} as const; } 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 { interface DeleteGroupQueryParams {
pid: string; pid: string;
} }
@@ -132,7 +197,7 @@ export const deleteGroup = async (req: Request<DeleteGroupQueryParams>, res: Res
return res.status(204).end(); return res.status(204).end();
} catch (e) { } catch (e) {
if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { 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; throw e;
+163
View File
@@ -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;
}
};
+159
View File
@@ -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 } },
});
}
+10
View File
@@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { z } from "zod";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { DurationSchemaT, parseSchema, PointSchemaT } from "../lib/result_schema"; import { DurationSchemaT, parseSchema, PointSchemaT } from "../lib/result_schema";
import NotFoundError from "../Middleware/error/NotFoundError"; import NotFoundError from "../Middleware/error/NotFoundError";
@@ -8,12 +9,21 @@ import SchemaError from "../Middleware/error/SchemaError";
import { import {
createInsufficientPermissionsError, createInsufficientPermissionsError,
DataType, DataType,
generateError,
generateInvalidBodyError, generateInvalidBodyError,
NAME_ERROR, NAME_ERROR,
validateName, validateName,
} from "./common"; } from "./common";
const RoleSchemaBody = z.object({
name: z.string().min(1),
schema: z.string(),
});
const UpdateRoleSchema = RoleSchemaBody.partial();
const roleSchema = { const roleSchema = {
pid: true,
name: true, name: true,
schema: true, schema: true,
discipline: { select: { pid: true, name: true } }, discipline: { select: { pid: true, name: true } },
+98
View File
@@ -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");
};
+110 -13
View File
@@ -3,27 +3,113 @@ import prisma from "../lib/prisma";
import { mailClient } from "../lib/redis"; import { mailClient } from "../lib/redis";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { verificationMail } from "../lib/mail"; 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) => { const TeamBody = z.object({
//TODO: Implemnt user endpoint and use following code to send verification mail 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 = { interface CreateTeamBody {
//Supposed to come from database teamName: string;
id: "10", leaderEmail: string;
email: "[email protected]", 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(); 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) => { export const verifyEmail = async (req: Request, res: Response) => {
const { code } = req.body || {}; const { code } = req.params || {};
if (!(typeof code === "string")) { if (!(typeof code === "string")) {
return res.status(400).json({ 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: { where: {
id: parseInt(acc), pid: acc,
}, },
data: { data: {
verified: true, 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
}; };
+157 -63
View File
@@ -1,45 +1,96 @@
/// <reference path="../../custom.d.ts" /> /// <reference path="../../custom.d.ts" />
import { NextFunction, Request, Response } from "express"; 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 { authClient } from "../../lib/redis";
import jwt, { JsonWebTokenError, JwtPayload } from "jsonwebtoken"; import jwt, { JsonWebTokenError, JwtPayload } from "jsonwebtoken";
import prisma from "../../lib/prisma"; 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) => { export const getBearerToken = (authorization: string) => authorization.slice(7);
const { authorization } = req.headers;
if (!authorization) { const _requireAdminAuthentication =
return res.status(403).send({ (config: { optional?: Boolean; controlled?: Boolean } = { optional: false, controlled: false }) =>
type: "error", async (req: Request, res: Response, next: NextFunction) => {
payload: { if (!JWT_SECRET) {
message: "The requeset did not include the Authorization header", throw new Error("JWT_SECRET not set");
}, }
});
}
if (!verifyAuthorizationFormat(authorization)) { const { authorization } = req.headers;
return res.status(400).send({
type: "error",
payload: {
message: "Malformed Authorization header",
format: "Bearer <token>",
},
});
}
let token_payload_: string | JwtPayload; if (!authorization) {
if (config.optional) {
return false;
}
try { return res.status(403).send({
token_payload_ = jwt.verify(getBearerToken(authorization), JWT_SECRET); type: "error",
} catch (e) { payload: {
if (e instanceof JsonWebTokenError) { 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 <token>",
},
});
}
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({ return res.status(403).json({
type: "error", type: "error",
payload: { 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; if (!config.controlled) {
next();
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 true;
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,
}; };
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!");
}
}
+106
View File
@@ -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 <token>",
},
});
}
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");
}
}
+13
View File
@@ -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";
}
}
+27
View File
@@ -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",
},
});
}
+2
View File
@@ -5,6 +5,7 @@ import {
deleteDiscipline, deleteDiscipline,
getAllDisciplines, getAllDisciplines,
getDiscipline, getDiscipline,
updateDiscipline,
} from "../Controllers/discipline.controller"; } from "../Controllers/discipline.controller";
import { requireAuthentication } from "../Middleware/auth/auth"; import { requireAuthentication } from "../Middleware/auth/auth";
@@ -14,6 +15,7 @@ router.get("/", getAllDisciplines); // TODO: Optional auth
router.get("/:pid", getDiscipline); router.get("/:pid", getDiscipline);
router.patch("/:pid", requireAuthentication, updateDiscipline);
router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteDiscipline); router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteDiscipline);
eventRouter.post("/:eventPid/disciplines", requireAuthentication, createDiscipline); eventRouter.post("/:eventPid/disciplines", requireAuthentication, createDiscipline);
+2
View File
@@ -5,6 +5,7 @@ import {
getAllGroups, getAllGroups,
getAllGroupsWithParam, getAllGroupsWithParam,
getGroup, getGroup,
updateGroup,
} from "../Controllers/group.controllers"; } from "../Controllers/group.controllers";
import { requireAuthentication } from "../Middleware/auth/auth"; import { requireAuthentication } from "../Middleware/auth/auth";
import organisationRouter from "./organisation.routes"; import organisationRouter from "./organisation.routes";
@@ -14,6 +15,7 @@ const router = express.Router();
router.get("/", getAllGroups); router.get("/", getAllGroups);
router.get("/:pid", getGroup); router.get("/:pid", getGroup);
router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteGroup); router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteGroup);
router.patch("/:pid", requireAuthentication, updateGroup);
organisationRouter.get("/:organisationPid/groups", getAllGroupsWithParam); organisationRouter.get("/:organisationPid/groups", getAllGroupsWithParam);
organisationRouter.post("/:organisationPid/groups", requireAuthentication, createGroup); organisationRouter.post("/:organisationPid/groups", requireAuthentication, createGroup);
+30
View File
@@ -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;
+9
View File
@@ -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);
+14
View File
@@ -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;
+10
View File
@@ -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;
+46 -14
View File
@@ -3,6 +3,7 @@ import prisma from "./lib/prisma";
import eventRouter from "./Routes/event.routes"; import eventRouter from "./Routes/event.routes";
import adminAuthRouter from "./Routes/admin_auth.routes"; import adminAuthRouter from "./Routes/admin_auth.routes";
import argon2 from "argon2"; import argon2 from "argon2";
import cors from "cors";
import adminRouter from "./Routes/admin.routes"; import adminRouter from "./Routes/admin.routes";
import organisationRouter from "./Routes/organisation.routes"; import organisationRouter from "./Routes/organisation.routes";
import groupRouter from "./Routes/group.routes"; import groupRouter from "./Routes/group.routes";
@@ -12,6 +13,9 @@ import defaultErrorHandler from "./Middleware/error/handler";
import logger from "./Middleware/error/logger"; import logger from "./Middleware/error/logger";
import debugLogger from "./Middleware/debug/logger"; import debugLogger from "./Middleware/debug/logger";
import mediaRouter from "./Routes/media.routes"; 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 // Set up async error handling
require("express-async-errors"); require("express-async-errors");
@@ -20,21 +24,40 @@ require("dotenv").config(); // Load dotenv config
const app = express(); const app = express();
if (process.env.NODE_ENV === "development") {
logger.info("Using development mode");
}
async function main() { async function main() {
// Dev if (process.env.NODE_ENV === "development") {
await prisma.admin.upsert({ logger.info("Using development mode");
where: { id: 1 }, logger.warning(
create: { "This mode should not be used in any production-near environment as it is significantly less secure than the production mode"
name: "admin", );
password: await argon2.hash("test", { type: argon2.argon2id }),
permission_level: "ELEVATED", // TODO: How should you login to the prod server by default? Maybe random password?
}, await prisma.admin.upsert({
update: {}, 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 // Todo: Everything
@@ -62,9 +85,18 @@ async function main() {
app.use("/api/media", mediaRouter); app.use("/api/media", mediaRouter);
app.use("/api/users", userRouter);
app.use("/api/teams", TeamRouter);
app.get("/", rootHandler);
app.get("/api", rootHandler);
// Error handling // Error handling
app.use(defaultErrorHandler); // This has to be the LAST ROUTE app.use(defaultErrorHandler); // This has to be the LAST ROUTE
app.use(notFoundHandler);
app.listen(process.env.PORT, () => { app.listen(process.env.PORT, () => {
logger.info(`Listening on port ${process.env.PORT}`); logger.info(`Listening on port ${process.env.PORT}`);
}); });
+2
View File
@@ -1,7 +1,9 @@
import { AuthJWTPayload } from "./Controllers/admin_auth.controller"; import { AuthJWTPayload } from "./Controllers/admin_auth.controller";
import { TeamleaderJWTPayload } from "./Middleware/auth/teamleaderAuth";
declare module "express-serve-static-core" { declare module "express-serve-static-core" {
interface Request { interface Request {
auth?: AuthJWTPayload & { isAuthenticated: boolean }; auth?: AuthJWTPayload & { isAuthenticated: boolean };
teamleader?: TeamleaderJWTPayload & { isAuthenticated: boolean };
} }
} }
+14 -4
View File
@@ -5,14 +5,21 @@ import nodemailer from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport"; import SMTPTransport from "nodemailer/lib/smtp-transport";
import mjml from "./mjml"; 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 }; export let mailAccount = { user: process.env.MAILUSER + "@mail." + process.env.DOMAIN, pass: process.env.MAILPASSWORD };
let transporter = let transporter =
process.env.DEV == "true" || process.env.DOMAIN == undefined process.env.NODE_ENV == "development" || process.env.DOMAIN == undefined
? (async () => { ? (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") { if (process.env.NODE_ENV != "test") {
console.log(mailAccount); console.log(mailAccount);
} }
@@ -43,6 +50,8 @@ let transporter =
); );
const sendMail = async (from: string, to: string, subject: string, text?: string, html?: string) => { const sendMail = async (from: string, to: string, subject: string, text?: string, html?: string) => {
logger.debug(`Sent email to: ${to}`);
return await ( return await (
await transporter await transporter
).sendMail({ ).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) => { export const verificationMail = async (to: string, eventName: string, verificationLink: string) => {
const raw = mjml.getTemplate("emailVerification"); 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 message = Handlebars.compile(raw);
const data = { eventName, verificationLink }; const data = { eventName, verificationLink };