diff --git a/backend/.env.test b/backend/.env.test index fb94aa09..2d928db0 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -8,6 +8,7 @@ ### Dwengo ### DWENGO_PORT=3000 +DWENGO_RUN_MODE=test DWENGO_DB_NAME=":memory:" DWENGO_DB_UPDATE=true diff --git a/backend/package.json b/backend/package.json index 36141a6d..b0264ea8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,6 +29,7 @@ "cross-env": "^7.0.3", "dotenv": "^16.4.7", "express": "^5.0.1", + "express-fileupload": "^1.5.1", "express-jwt": "^8.5.1", "gift-pegjs": "^1.0.2", "isomorphic-dompurify": "^2.22.0", @@ -38,9 +39,11 @@ "loki-logger-ts": "^1.0.2", "marked": "^15.0.7", "memjs": "^1.3.2", + "mime-types": "^3.0.1", "nanoid": "^5.1.5", "response-time": "^2.3.3", "swagger-ui-express": "^5.0.1", + "unzipper": "^0.12.3", "uuid": "^11.1.0", "winston": "^3.17.0", "winston-loki": "^6.1.3" @@ -49,11 +52,14 @@ "@mikro-orm/cli": "6.4.12", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/express-fileupload": "^1.5.1", "@types/js-yaml": "^4.0.9", "@types/memjs": "^1.3.3", + "@types/mime-types": "^2.1.4", "@types/node": "^22.13.4", "@types/response-time": "^2.3.8", "@types/swagger-ui-express": "^4.1.8", + "@types/unzipper": "^0.10.11", "globals": "^15.15.0", "ts-node": "^10.9.2", "tsx": "^4.19.3", diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 49e2159b..0a249c5b 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -1,10 +1,11 @@ import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; import { getLogger } from '../logging/initalize.js'; import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; -import { createOrUpdateStudent } from '../services/students.js'; -import { createOrUpdateTeacher } from '../services/teachers.js'; import { envVars, getEnvVar } from '../util/envVars.js'; -import { Response } from 'express'; +import { createOrUpdateStudent } from '../services/students.js'; +import { Request, Response } from 'express'; +import { createOrUpdateTeacher } from '../services/teachers.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; interface FrontendIdpConfig { authority: string; @@ -40,6 +41,10 @@ export function getFrontendAuthConfig(): FrontendAuthConfig { }; } +export function handleGetFrontendAuthConfig(_req: Request, res: Response): void { + res.json(getFrontendAuthConfig()); +} + export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise { const auth = req.auth; if (!auth) { @@ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response) firstName: auth.firstName ?? '', lastName: auth.lastName ?? '', }; - if (auth.accountType === 'student') { + if (auth.accountType === AccountType.Student) { await createOrUpdateStudent(userData); logger.debug(`Synchronized student ${userData.username} with IDP`); } else { diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index 83aa33f9..e4318efa 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -7,6 +7,9 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { envVars, getEnvVar } from '../util/envVars.js'; import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { UploadedFile } from 'express-fileupload'; +import { AuthenticatedRequest } from '../middleware/auth/authenticated-request'; +import { requireFields } from './error-helper.js'; function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { if (!req.params.hruid) { @@ -20,27 +23,35 @@ function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIde } function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { - if (!req.query.hruid) { - throw new BadRequestException('HRUID is required.'); - } + const { hruid, language } = req.params; + requireFields({ hruid }); + return { - hruid: req.params.hruid, - language: (req.query.language as Language) || FALLBACK_LANG, + hruid, + language: (language as Language) || FALLBACK_LANG, }; } export async function getAllLearningObjects(req: Request, res: Response): Promise { - const learningPathId = getLearningPathIdentifierFromRequest(req); - const full = req.query.full; + if (req.query.admin) { + // If the admin query parameter is present, the user wants to have all learning objects with this admin. + const learningObjects = await learningObjectService.getLearningObjectsAdministratedBy(req.query.admin as string); - let learningObjects: FilteredLearningObject[] | string[]; - if (full) { - learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); + res.json(learningObjects); } else { - learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); - } + // Else he/she wants all learning objects on the path specified by the request parameters. + const learningPathId = getLearningPathIdentifierFromRequest(req); + const full = req.query.full; - res.json({ learningObjects: learningObjects }); + let learningObjects: FilteredLearningObject[] | string[]; + if (full) { + learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); + } else { + learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); + } + + res.json({ learningObjects: learningObjects }); + } } export async function getLearningObject(req: Request, res: Response): Promise { @@ -72,3 +83,32 @@ export async function getAttachment(req: Request, res: Response): Promise } res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); } + +export async function handlePostLearningObject(req: AuthenticatedRequest, res: Response): Promise { + if (!req.files || !req.files.learningObject) { + throw new BadRequestException('No file uploaded'); + } + const learningObject = await learningObjectService.storeLearningObject((req.files.learningObject as UploadedFile).tempFilePath, [ + req.auth!.username, + ]); + res.json(learningObject); +} + +export async function handleDeleteLearningObject(req: AuthenticatedRequest, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + + if (!learningObjectId.version) { + throw new BadRequestException('When deleting a learning object, a version must be specified.'); + } + + const deletedLearningObject = await learningObjectService.deleteLearningObject({ + hruid: learningObjectId.hruid, + version: learningObjectId.version, + language: learningObjectId.language, + }); + if (deletedLearningObject) { + res.json(deletedLearningObject); + } else { + throw new NotFoundException('Learning object not found'); + } +} diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 1bd3f2b1..7fdefd2d 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -7,51 +7,103 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { Group } from '../entities/assignments/group.entity.js'; import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; +import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; +import { LearningPath, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { getTeacher } from '../services/teachers.js'; +import { requireFields } from './error-helper.js'; /** * Fetch learning paths based on query parameters. */ export async function getLearningPaths(req: Request, res: Response): Promise { - const hruids = req.query.hruid; - const themeKey = req.query.theme as string; - const searchQuery = req.query.search as string; - const language = (req.query.language as string) || FALLBACK_LANG; - - const forGroupNo = req.query.forGroup as string; - const assignmentNo = req.query.assignmentNo as string; - const classId = req.query.classId as string; - - let forGroup: Group | undefined; - - if (forGroupNo) { - if (!assignmentNo || !classId) { - throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); - } - const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); - if (assignment) { - forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; - } - } - - let hruidList; - - if (hruids) { - hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; - } else if (themeKey) { - const theme = themes.find((t) => t.title === themeKey); - if (theme) { - hruidList = theme.hruids; - } else { - throw new NotFoundException(`Theme "${themeKey}" not found.`); - } - } else if (searchQuery) { - const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); - res.json(searchResults); - return; + const admin = req.query.admin; + if (admin) { + const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string); + res.json(paths); } else { - hruidList = themes.flatMap((theme) => theme.hruids); - } + const hruids = req.query.hruid; + const themeKey = req.query.theme as string; + const searchQuery = req.query.search as string; + const language = (req.query.language as string) || FALLBACK_LANG; - const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); - res.json(learningPaths.data); + const forGroupNo = req.query.forGroup as string; + const assignmentNo = req.query.assignmentNo as string; + const classId = req.query.classId as string; + + let forGroup: Group | undefined; + + if (forGroupNo) { + if (!assignmentNo || !classId) { + throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); + } + const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); + if (assignment) { + forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; + } + } + + let hruidList; + + if (hruids) { + hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; + } else if (themeKey) { + const theme = themes.find((t) => t.title === themeKey); + if (theme) { + hruidList = theme.hruids; + } else { + throw new NotFoundException(`Theme "${themeKey}" not found.`); + } + } else if (searchQuery) { + const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); + res.json(searchResults); + return; + } else { + hruidList = themes.flatMap((theme) => theme.hruids); + } + + const learningPaths = await learningPathService.fetchLearningPaths( + hruidList, + language as Language, + `HRUIDs: ${hruidList.join(', ')}`, + forGroup + ); + res.json(learningPaths.data); + } +} + +function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise { + return async (req, res) => { + const path = req.body as LearningPath; + const { hruid: hruidParam, language: languageParam } = req.params; + + if (isPut) { + requireFields({ hruidParam, languageParam, path }); + } + + const teacher = await getTeacher(req.auth!.username); + if (isPut) { + if (req.params.hruid !== path.hruid || req.params.language !== path.language) { + throw new BadRequestException('id_not_matching_query_params'); + } + await learningPathService.deleteLearningPath({ hruid: path.hruid, language: path.language as Language }); + } + res.json(await learningPathService.createNewLearningPath(path, [teacher])); + }; +} + +export const postLearningPath = postOrPutLearningPath(false); +export const putLearningPath = postOrPutLearningPath(true); + +export async function deleteLearningPath(req: AuthenticatedRequest, res: Response): Promise { + const { hruid, language } = req.params; + + requireFields({ hruid, language }); + + const id: LearningPathIdentifier = { hruid, language: language as Language }; + const deletedPath = await learningPathService.deleteLearningPath(id); + if (deletedPath) { + res.json(deletedPath); + } else { + throw new NotFoundException('The learning path could not be found.'); + } } diff --git a/backend/src/controllers/students.ts b/backend/src/controllers/students.ts index 229cff7e..e4c49683 100644 --- a/backend/src/controllers/students.ts +++ b/backend/src/controllers/students.ts @@ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response): const classId = req.body.classId; requireFields({ username, classId }); - const request = await createClassJoinRequest(username, classId); + const request = await createClassJoinRequest(username, classId.toUpperCase()); res.json({ request }); } diff --git a/backend/src/controllers/teacher-invitations.ts b/backend/src/controllers/teacher-invitations.ts index 932bb1af..9e8eee6e 100644 --- a/backend/src/controllers/teacher-invitations.ts +++ b/backend/src/controllers/teacher-invitations.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { requireFields } from './error-helper.js'; import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { ConflictException } from '../exceptions/conflict-exception.js'; export async function getAllInvitationsHandler(req: Request, res: Response): Promise { const username = req.params.username; @@ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom const classId = req.body.class; requireFields({ sender, receiver, classId }); + if (sender === receiver) { + throw new ConflictException('Cannot send an invitation to yourself'); + } + const data = req.body as TeacherInvitationData; const invitation = await createInvitation(data); diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index c9f1f189..e793b991 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -12,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository { + return this.find( + { + admins: { + username: adminUsername, + }, + }, + { populate: ['admins'] } // Make sure to load admin relations + ); + } + + public async removeByIdentifier(identifier: LearningObjectIdentifier): Promise { + const learningObject = await this.findByIdentifier(identifier); + if (learningObject) { + await this.em.removeAndFlush(learningObject); + } + return learningObject; + } } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 67f08a03..238a7676 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -4,11 +4,10 @@ import { Language } from '@dwengo-1/common/util/language'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { RequiredEntityData } from '@mikro-orm/core'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; -import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js'; export class LearningPathRepository extends DwengoEntityRepository { public async findByHruidAndLanguage(hruid: string, language: Language): Promise { - return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); + return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions', 'admins'] }); } /** @@ -24,7 +23,21 @@ export class LearningPathRepository extends DwengoEntityRepository language: language, $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], }, - populate: ['nodes', 'nodes.transitions'], + populate: ['nodes', 'nodes.transitions', 'admins'], + }); + } + + /** + * Returns all learning paths which have the user with the given username as an administrator. + */ + public async findAllByAdminUsername(adminUsername: string): Promise { + return this.findAll({ + where: { + admins: { + username: adminUsername, + }, + }, + populate: ['nodes', 'nodes.transitions', 'admins'], }); } @@ -36,18 +49,15 @@ export class LearningPathRepository extends DwengoEntityRepository return this.em.create(LearningPathTransition, transitionData); } - public async saveLearningPathNodesAndTransitions( - path: LearningPath, - nodes: LearningPathNode[], - transitions: LearningPathTransition[], - options?: { preventOverwrite?: boolean } - ): Promise { - if (options?.preventOverwrite && (await this.findOne(path))) { - throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); + /** + * Deletes the learning path with the given hruid and language. + * @returns the deleted learning path or null if it was not found. + */ + public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise { + const path = await this.findByHruidAndLanguage(hruid, language); + if (path) { + await this.em.removeAndFlush(path); } - const em = this.getEntityManager(); - await em.persistAndFlush(path); - await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); - await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); + return path; } } diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index b9935b16..f681eebb 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -3,9 +3,9 @@ import { Question } from '../../entities/questions/question.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Student } from '../../entities/users/student.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { Group } from '../../entities/assignments/group.entity.js'; import { Assignment } from '../../entities/assignments/assignment.entity.js'; import { Loaded } from '@mikro-orm/core'; -import { Group } from '../../entities/assignments/group.entity'; export class QuestionRepository extends DwengoEntityRepository { public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise { diff --git a/backend/src/entities/content/attachment.entity.ts b/backend/src/entities/content/attachment.entity.ts index 80104f28..99901495 100644 --- a/backend/src/entities/content/attachment.entity.ts +++ b/backend/src/entities/content/attachment.entity.ts @@ -9,6 +9,7 @@ export class Attachment { @ManyToOne({ entity: () => LearningObject, primary: true, + deleteRule: 'cascade', }) learningObject!: LearningObject; diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index e0ae09d6..1d129b9c 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -1,4 +1,4 @@ -import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { ArrayType, Collection, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Attachment } from './attachment.entity.js'; import { Teacher } from '../users/teacher.entity.js'; import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; @@ -28,7 +28,7 @@ export class LearningObject { @ManyToMany({ entity: () => Teacher, }) - admins!: Teacher[]; + admins: Collection = new Collection(this); @Property({ type: 'string' }) title!: string; @@ -84,7 +84,7 @@ export class LearningObject { entity: () => Attachment, mappedBy: 'learningObject', }) - attachments: Attachment[] = []; + attachments: Collection = new Collection(this); @Property({ type: 'blob' }) content!: Buffer; diff --git a/backend/src/entities/content/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts index fd870dcd..08543d22 100644 --- a/backend/src/entities/content/learning-path-node.entity.ts +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -1,4 +1,4 @@ -import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; +import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; import { LearningPath } from './learning-path.entity.js'; import { LearningPathTransition } from './learning-path-transition.entity.js'; import { Language } from '@dwengo-1/common/util/language'; @@ -26,7 +26,7 @@ export class LearningPathNode { @Property({ type: 'bool' }) startNode!: boolean; - @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) + @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node', cascade: [Cascade.ALL] }) transitions!: Collection; @Property({ length: 3 }) diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index 1b96d8ea..36e13766 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -1,4 +1,4 @@ -import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Cascade, Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; import { LearningPathNode } from './learning-path-node.entity.js'; @@ -24,6 +24,6 @@ export class LearningPath { @Property({ type: 'blob', nullable: true }) image: Buffer | null = null; - @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) + @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath', cascade: [Cascade.ALL] }) nodes: Collection = new Collection(this); } diff --git a/backend/src/interfaces/user.ts b/backend/src/interfaces/user.ts index f4413b5e..3084c494 100644 --- a/backend/src/interfaces/user.ts +++ b/backend/src/interfaces/user.ts @@ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO { }; } +export function mapToUsername(user: { username: string }): string { + return user.username; +} + export function mapToUser(userData: UserDTO, userInstance: T): T { userInstance.username = userData.username; userInstance.firstName = userData.firstName; diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts index 73a65b9a..24be4825 100644 --- a/backend/src/middleware/auth/auth.ts +++ b/backend/src/middleware/auth/auth.ts @@ -7,7 +7,6 @@ import * as express from 'express'; import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticationInfo } from './authentication-info.js'; import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; -import { ForbiddenException } from '../../exceptions/forbidden-exception.js'; const JWKS_CACHE = true; const JWKS_RATE_LIMIT = true; @@ -108,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response } export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; - -/** - * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill - * the given access condition. - * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates - * to true. - */ -export function authorize(accessCondition: (auth: AuthenticationInfo) => boolean) { - return (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void => { - if (!req.auth) { - throw new UnauthorizedException(); - } else if (!accessCondition(req.auth)) { - throw new ForbiddenException(); - } else { - next(); - } - }; -} - -/** - * Middleware which rejects all unauthenticated users, but accepts all authenticated users. - */ -export const authenticatedOnly = authorize((_) => true); - -/** - * Middleware which rejects requests from unauthenticated users or users that aren't students. - */ -export const studentsOnly = authorize((auth) => auth.accountType === 'student'); - -/** - * Middleware which rejects requests from unauthenticated users or users that aren't teachers. - */ -export const teachersOnly = authorize((auth) => auth.accountType === 'teacher'); diff --git a/backend/src/middleware/auth/authenticated-request.d.ts b/backend/src/middleware/auth/authenticated-request.d.ts index 9737fa7e..af7630af 100644 --- a/backend/src/middleware/auth/authenticated-request.d.ts +++ b/backend/src/middleware/auth/authenticated-request.d.ts @@ -1,8 +1,15 @@ import { Request } from 'express'; import { JwtPayload } from 'jsonwebtoken'; import { AuthenticationInfo } from './authentication-info.js'; +import * as core from 'express-serve-static-core'; -export interface AuthenticatedRequest extends Request { +export interface AuthenticatedRequest< + P = core.ParamsDictionary, + ResBody = unknown, + ReqBody = unknown, + ReqQuery = core.Query, + Locals extends Record = Record, +> extends Request { // Properties are optional since the user is not necessarily authenticated. jwtPayload?: JwtPayload; auth?: AuthenticationInfo; diff --git a/backend/src/middleware/auth/checks/assignment-auth-checks.ts b/backend/src/middleware/auth/checks/assignment-auth-checks.ts new file mode 100644 index 00000000..bd9f51d7 --- /dev/null +++ b/backend/src/middleware/auth/checks/assignment-auth-checks.ts @@ -0,0 +1,21 @@ +import { authorize } from './auth-checks.js'; +import { fetchClass } from '../../../services/classes.js'; +import { fetchAllGroups } from '../../../services/groups.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +/** + * Expects the path to contain the path parameters 'classId' and 'id' (meaning the ID of the assignment). + * Only allows requests from users who are + * - either teachers of the class the assignment was posted in, + * - or students in a group of the assignment. + */ +export const onlyAllowIfHasAccessToAssignment = authorize(async (auth, req) => { + const { classid: classId, id: assignmentId } = req.params as { classid: string; id: number }; + if (auth.accountType === AccountType.Teacher) { + const clazz = await fetchClass(classId); + return clazz.teachers.map(mapToUsername).includes(auth.username); + } + const groups = await fetchAllGroups(classId, assignmentId); + return groups.some((group) => group.members.map((member) => member.username).includes(auth.username)); +}); diff --git a/backend/src/middleware/auth/checks/auth-checks.ts b/backend/src/middleware/auth/checks/auth-checks.ts new file mode 100644 index 00000000..bf4891a3 --- /dev/null +++ b/backend/src/middleware/auth/checks/auth-checks.ts @@ -0,0 +1,61 @@ +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import * as express from 'express'; +import { RequestHandler } from 'express'; +import { UnauthorizedException } from '../../../exceptions/unauthorized-exception.js'; +import { ForbiddenException } from '../../../exceptions/forbidden-exception.js'; +import { envVars, getEnvVar } from '../../../util/envVars.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +/** + * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill + * the given access condition. + * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates + * to true. + */ +export function authorize>( + accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest) => boolean | Promise +): RequestHandler { + // Bypass authentication during testing + if (getEnvVar(envVars.RunMode) === 'test') { + return async ( + _req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { + next(); + }; + } + + return async ( + req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { + if (!req.auth) { + throw new UnauthorizedException(); + } else if (!(await accessCondition(req.auth, req))) { + throw new ForbiddenException(); + } else { + next(); + } + }; +} + +/** + * Middleware which rejects all unauthenticated users, but accepts all authenticated users. + */ +export const authenticatedOnly = authorize((_) => true); +/** + * Middleware which rejects requests from unauthenticated users or users that aren't students. + */ +export const studentsOnly = authorize((auth) => auth.accountType === AccountType.Student); +/** + * Middleware which rejects requests from unauthenticated users or users that aren't teachers. + */ +export const teachersOnly = authorize((auth) => auth.accountType === AccountType.Teacher); +/** + * Middleware which is to be used on requests no normal user should be able to execute. + * Since there is no concept of administrator accounts yet, currently, those requests will always be blocked. + */ +export const adminOnly = authorize(() => false); diff --git a/backend/src/middleware/auth/checks/class-auth-checks.ts b/backend/src/middleware/auth/checks/class-auth-checks.ts new file mode 100644 index 00000000..ea75d21d --- /dev/null +++ b/backend/src/middleware/auth/checks/class-auth-checks.ts @@ -0,0 +1,70 @@ +import { authorize } from './auth-checks.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import { fetchClass } from '../../../services/classes.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { getAllInvitations } from '../../../services/teacher-invitations.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +async function teaches(teacherUsername: string, classId: string): Promise { + const clazz = await fetchClass(classId); + return clazz.teachers.map(mapToUsername).includes(teacherUsername); +} + +/** + * To be used on a request with path parameters username and classId. + * Only allows requests whose username parameter is equal to the username of the user who is logged in and requests + * whose classId parameter references a class the logged-in user is a teacher of. + */ +export const onlyAllowStudentHimselfAndTeachersOfClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + if (req.params.username === auth.username) { + return true; + } else if (auth.accountType === AccountType.Teacher) { + return teaches(auth.username, req.params.classId); + } + return false; +}); + +/** + * Only let the request pass through if its path parameter "username" is the username of the currently logged-in + * teacher and the path parameter "classId" refers to a class the teacher teaches. + */ +export const onlyAllowTeacherOfClass = authorize( + async (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username && teaches(auth.username, req.params.classId) +); + +/** + * Only let the request pass through if the class id in it refers to a class the current user is in (as a student + * or teacher) + */ +export const onlyAllowIfInClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const classId = req.params.classId ?? req.params.classid ?? req.params.id; + const clazz = await fetchClass(classId); + if (auth.accountType === AccountType.Teacher) { + return clazz.teachers.map(mapToUsername).includes(auth.username); + } + return clazz.students.map(mapToUsername).includes(auth.username); +}); + +export const onlyAllowIfInClassOrInvited = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const classId = req.params.classId ?? req.params.classid ?? req.params.id; + const clazz = await fetchClass(classId); + if (auth.accountType === AccountType.Teacher) { + const invitations = await getAllInvitations(auth.username, false); + return clazz.teachers.map(mapToUsername).includes(auth.username) || invitations.some((invitation) => invitation.classId === classId); + } + return clazz.students.map(mapToUsername).includes(auth.username); +}); + +/** + * Only allows the request to pass if the 'class' property in its body is a class the current user is a member of. + */ +export const onlyAllowOwnClassInBody = authorize(async (auth, req) => { + const classId = (req.body as { class: string })?.class; + const clazz = await fetchClass(classId); + + if (auth.accountType === AccountType.Teacher) { + return clazz.teachers.map(mapToUsername).includes(auth.username); + } + return clazz.students.map(mapToUsername).includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/group-auth-checker.ts b/backend/src/middleware/auth/checks/group-auth-checker.ts new file mode 100644 index 00000000..563edf57 --- /dev/null +++ b/backend/src/middleware/auth/checks/group-auth-checker.ts @@ -0,0 +1,26 @@ +import { authorize } from './auth-checks.js'; +import { fetchClass } from '../../../services/classes.js'; +import { fetchGroup } from '../../../services/groups.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +/** + * Expects the path to contain the path parameters 'classid', 'assignmentid' and 'groupid'. + * Only allows requests from users who are + * - either teachers of the class the assignment for the group was posted in, + * - or students in the group + */ +export const onlyAllowIfHasAccessToGroup = authorize(async (auth, req) => { + const { + classid: classId, + assignmentid: assignmentId, + groupid: groupId, + } = req.params as { classid: string; assignmentid: number; groupid: number }; + + if (auth.accountType === AccountType.Teacher) { + const clazz = await fetchClass(classId); + return clazz.teachers.map(mapToUsername).includes(auth.username); + } // User is student + const group = await fetchGroup(classId, assignmentId, groupId); + return group.members.map(mapToUsername).includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/learning-content-auth-checks.ts b/backend/src/middleware/auth/checks/learning-content-auth-checks.ts new file mode 100644 index 00000000..6942b425 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-content-auth-checks.ts @@ -0,0 +1,21 @@ +import { authorize } from './auth-checks'; +import { AuthenticationInfo } from '../authentication-info'; +import { AuthenticatedRequest } from '../authenticated-request'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +/** + * Only allows requests whose learning path personalization query parameters ('forGroup' / 'assignmentNo' / 'classId') + * are + * - either not set + * - or set to a group the user is in, + * - or set to anything if the user is a teacher. + */ +export const onlyAllowPersonalizationForOwnGroup = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const { forGroup, assignmentNo, classId } = req.params; + if (auth.accountType === AccountType.Student && forGroup && assignmentNo && classId) { + // TODO: groupNumber? + // Const group = await fetchGroup(Number(classId), Number(assignmentNo), ) + return false; + } + return true; +}); diff --git a/backend/src/middleware/auth/checks/learning-object-auth-checks.ts b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts new file mode 100644 index 00000000..9bf94799 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts @@ -0,0 +1,16 @@ +import { Language } from '@dwengo-1/common/util/language'; +import learningObjectService from '../../../services/learning-objects/learning-object-service.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { authorize } from './auth-checks.js'; + +export const onlyAdminsForLearningObject = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const { hruid } = req.params; + const { version, language } = req.query; + const admins = await learningObjectService.getAdmins({ + hruid, + language: language as Language, + version: parseInt(version as string), + }); + return admins.includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/learning-path-auth-checks.ts b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts new file mode 100644 index 00000000..3668ab07 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts @@ -0,0 +1,13 @@ +import { Language } from '@dwengo-1/common/util/language'; +import learningPathService from '../../../services/learning-paths/learning-path-service.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { authorize } from './auth-checks.js'; + +export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const adminsForLearningPath = await learningPathService.getAdmins({ + hruid: req.params.hruid, + language: req.params.language as Language, + }); + return adminsForLearningPath && adminsForLearningPath.includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/question-checks.ts b/backend/src/middleware/auth/checks/question-checks.ts new file mode 100644 index 00000000..76ede049 --- /dev/null +++ b/backend/src/middleware/auth/checks/question-checks.ts @@ -0,0 +1,66 @@ +import { authorize } from './auth-checks.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import { requireFields } from '../../../controllers/error-helper.js'; +import { getLearningObjectId, getQuestionId } from '../../../controllers/questions.js'; +import { fetchQuestion } from '../../../services/questions.js'; +import { FALLBACK_SEQ_NUM } from '../../../config.js'; +import { fetchAnswer } from '../../../services/answers.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +export const onlyAllowAuthor = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { author: string }).author === auth.username +); + +export const onlyAllowAuthorRequest = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const question = await fetchQuestion(questionId); + + return question.author.username === auth.username; +}); + +export const onlyAllowAuthorRequestAnswer = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + const seqAnswer = req.params.seqAnswer; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM; + const answer = await fetchAnswer(questionId, sequenceNumber); + + return answer.author.username === auth.username; +}); + +export const onlyAllowIfHasAccessToQuestion = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const question = await fetchQuestion(questionId); + const group = question.inGroup; + + if (auth.accountType === AccountType.Teacher) { + const cls = group.assignment.within; // TODO check if contains full objects + return cls.teachers.map(mapToUsername).includes(auth.username); + } // User is student + return group.members.map(mapToUsername).includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/submission-checks.ts b/backend/src/middleware/auth/checks/submission-checks.ts new file mode 100644 index 00000000..893371c2 --- /dev/null +++ b/backend/src/middleware/auth/checks/submission-checks.ts @@ -0,0 +1,28 @@ +import { languageMap } from '@dwengo-1/common/util/language'; +import { LearningObjectIdentifier } from '../../../entities/content/learning-object-identifier.js'; +import { fetchSubmission } from '../../../services/submissions.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { authorize } from './auth-checks.js'; +import { FALLBACK_LANG } from '../../../config.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +export const onlyAllowSubmitter = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username +); + +export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const { hruid: lohruid, id: submissionNumber } = req.params; + const { language: lang, version: version } = req.query; + + const loId = new LearningObjectIdentifier(lohruid, languageMap[lang as string] ?? FALLBACK_LANG, Number(version)); + const submission = await fetchSubmission(loId, Number(submissionNumber)); + + if (auth.accountType === AccountType.Teacher) { + // Dit kan niet werken om dat al deze objecten niet gepopulate zijn. + return submission.onBehalfOf.assignment.within.teachers.map(mapToUsername).includes(auth.username); + } + + return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/teacher-invitation-checks.ts b/backend/src/middleware/auth/checks/teacher-invitation-checks.ts new file mode 100644 index 00000000..0c6a790f --- /dev/null +++ b/backend/src/middleware/auth/checks/teacher-invitation-checks.ts @@ -0,0 +1,17 @@ +import { authorize } from './auth-checks.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; + +export const onlyAllowSenderOrReceiver = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username || req.params.receiver === auth.username +); + +export const onlyAllowSender = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username); + +export const onlyAllowSenderBody = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { sender: string }).sender === auth.username +); + +export const onlyAllowReceiverBody = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { receiver: string }).receiver === auth.username +); diff --git a/backend/src/middleware/auth/checks/user-auth-checks.ts b/backend/src/middleware/auth/checks/user-auth-checks.ts new file mode 100644 index 00000000..27228369 --- /dev/null +++ b/backend/src/middleware/auth/checks/user-auth-checks.ts @@ -0,0 +1,8 @@ +import { authorize } from './auth-checks.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; + +/** + * Only allow the user whose username is in the path parameter "username" to access the endpoint. + */ +export const preventImpersonation = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username); diff --git a/backend/src/routes/answers.ts b/backend/src/routes/answers.ts index b74f76a0..58179197 100644 --- a/backend/src/routes/answers.ts +++ b/backend/src/routes/answers.ts @@ -1,16 +1,18 @@ import express from 'express'; import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; +import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { onlyAllowAuthor, onlyAllowAuthorRequestAnswer, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js'; const router = express.Router({ mergeParams: true }); -router.get('/', getAllAnswersHandler); +router.get('/', authenticatedOnly, getAllAnswersHandler); -router.post('/', createAnswerHandler); +router.post('/', teachersOnly, onlyAllowAuthor, createAnswerHandler); -router.get('/:seqAnswer', getAnswerHandler); +router.get('/:seqAnswer', onlyAllowIfHasAccessToQuestion, getAnswerHandler); -router.delete('/:seqAnswer', deleteAnswerHandler); +router.delete('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, deleteAnswerHandler); -router.put('/:seqAnswer', updateAnswerHandler); +router.put('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, updateAnswerHandler); export default router; diff --git a/backend/src/routes/assignments.ts b/backend/src/routes/assignments.ts index 4503414d..f0250550 100644 --- a/backend/src/routes/assignments.ts +++ b/backend/src/routes/assignments.ts @@ -9,22 +9,25 @@ import { putAssignmentHandler, } from '../controllers/assignments.js'; import groupRouter from './groups.js'; +import { teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { onlyAllowIfInClass } from '../middleware/auth/checks/class-auth-checks.js'; +import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js'; const router = express.Router({ mergeParams: true }); -router.get('/', getAllAssignmentsHandler); +router.get('/', teachersOnly, onlyAllowIfInClass, getAllAssignmentsHandler); -router.post('/', createAssignmentHandler); +router.post('/', teachersOnly, onlyAllowIfInClass, createAssignmentHandler); -router.get('/:id', getAssignmentHandler); +router.get('/:id', onlyAllowIfHasAccessToAssignment, getAssignmentHandler); -router.put('/:id', putAssignmentHandler); +router.put('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, putAssignmentHandler); -router.delete('/:id', deleteAssignmentHandler); +router.delete('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteAssignmentHandler); -router.get('/:id/submissions', getAssignmentsSubmissionsHandler); +router.get('/:id/submissions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentsSubmissionsHandler); -router.get('/:id/questions', getAssignmentQuestionsHandler); +router.get('/:id/questions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentQuestionsHandler); router.use('/:assignmentid/groups', groupRouter); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 6f153836..ce9ee866 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,28 +1,35 @@ import express from 'express'; -import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; -import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; +import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; +import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; + const router = express.Router(); // Returns auth configuration for frontend -router.get('/config', (_req, res) => { - res.json(getFrontendAuthConfig()); -}); +router.get('/config', handleGetFrontendAuthConfig); router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { - /* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */ + /* #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ res.json({ message: 'If you see this, you should be authenticated!' }); }); router.get('/testStudentsOnly', studentsOnly, (_req, res) => { - /* #swagger.security = [{ "student": [ ] }] */ + /* #swagger.security = [{ "studentProduction": [ ] }, { "studentStaging": [ ] }, { "studentDev": [ ] }] */ res.json({ message: 'If you see this, you should be a student!' }); }); router.get('/testTeachersOnly', teachersOnly, (_req, res) => { - /* #swagger.security = [{ "teacher": [ ] }] */ + /* #swagger.security = [{ "teacherProduction": [ ] }, { "teacherStaging": [ ] }, { "teacherDev": [ ] }] */ res.json({ message: 'If you see this, you should be a teacher!' }); }); -router.post('/hello', authenticatedOnly, postHelloHandler); +// This endpoint is called by the client when the user has just logged in. +// It creates or updates the user entity based on the authentication data the endpoint was called with. +router.post( + '/hello', + authenticatedOnly, + /* + #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] +*/ postHelloHandler +); export default router; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts index cef6fd72..8a35eb2a 100644 --- a/backend/src/routes/classes.ts +++ b/backend/src/routes/classes.ts @@ -14,33 +14,35 @@ import { putClassHandler, } from '../controllers/classes.js'; import assignmentRouter from './assignments.js'; +import { adminOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { onlyAllowIfInClass, onlyAllowIfInClassOrInvited } from '../middleware/auth/checks/class-auth-checks.js'; const router = express.Router(); -// Root endpoint used to search objects -router.get('/', getAllClassesHandler); +router.get('/', adminOnly, getAllClassesHandler); -router.post('/', createClassHandler); +router.post('/', teachersOnly, createClassHandler); -router.get('/:id', getClassHandler); +router.get('/:id', onlyAllowIfInClassOrInvited, getClassHandler); -router.put('/:id', putClassHandler); +router.put('/:id', teachersOnly, onlyAllowIfInClass, putClassHandler); -router.delete('/:id', deleteClassHandler); +router.delete('/:id', teachersOnly, onlyAllowIfInClass, deleteClassHandler); -router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); +router.get('/:id/teacher-invitations', teachersOnly, onlyAllowIfInClass, getTeacherInvitationsHandler); -router.get('/:id/students', getClassStudentsHandler); +router.get('/:id/students', onlyAllowIfInClass, getClassStudentsHandler); -router.post('/:id/students', addClassStudentHandler); +router.post('/:id/students', teachersOnly, onlyAllowIfInClass, addClassStudentHandler); -router.delete('/:id/students/:username', deleteClassStudentHandler); +router.delete('/:id/students/:username', teachersOnly, onlyAllowIfInClass, deleteClassStudentHandler); -router.get('/:id/teachers', getClassTeachersHandler); +router.get('/:id/teachers', onlyAllowIfInClass, getClassTeachersHandler); -router.post('/:id/teachers', addClassTeacherHandler); +// De combinatie van deze POST en DELETE endpoints kan lethal zijn +router.post('/:id/teachers', teachersOnly, onlyAllowIfInClass, addClassTeacherHandler); -router.delete('/:id/teachers/:username', deleteClassTeacherHandler); +router.delete('/:id/teachers/:username', teachersOnly, onlyAllowIfInClass, deleteClassTeacherHandler); router.use('/:classid/assignments', assignmentRouter); diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts index 3043c23b..e8cb4c2d 100644 --- a/backend/src/routes/groups.ts +++ b/backend/src/routes/groups.ts @@ -8,22 +8,24 @@ import { getGroupSubmissionsHandler, putGroupHandler, } from '../controllers/groups.js'; +import { onlyAllowIfHasAccessToGroup } from '../middleware/auth/checks/group-auth-checker.js'; +import { teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js'; const router = express.Router({ mergeParams: true }); -// Root endpoint used to search objects -router.get('/', getAllGroupsHandler); +router.get('/', onlyAllowIfHasAccessToAssignment, getAllGroupsHandler); -router.post('/', createGroupHandler); +router.post('/', teachersOnly, onlyAllowIfHasAccessToAssignment, createGroupHandler); -router.get('/:groupid', getGroupHandler); +router.get('/:groupid', onlyAllowIfHasAccessToAssignment, getGroupHandler); -router.put('/:groupid', putGroupHandler); +router.put('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, putGroupHandler); -router.delete('/:groupid', deleteGroupHandler); +router.delete('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteGroupHandler); -router.get('/:groupid/submissions', getGroupSubmissionsHandler); +router.get('/:groupid/submissions', onlyAllowIfHasAccessToGroup, getGroupSubmissionsHandler); -router.get('/:groupid/questions', getGroupQuestionsHandler); +router.get('/:groupid/questions', onlyAllowIfHasAccessToGroup, getGroupQuestionsHandler); export default router; diff --git a/backend/src/routes/learning-objects.ts b/backend/src/routes/learning-objects.ts index 7532765b..cae56b88 100644 --- a/backend/src/routes/learning-objects.ts +++ b/backend/src/routes/learning-objects.ts @@ -1,8 +1,17 @@ import express from 'express'; -import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; - +import { + getAllLearningObjects, + getAttachment, + getLearningObject, + getLearningObjectHTML, + handleDeleteLearningObject, + handlePostLearningObject, +} from '../controllers/learning-objects.js'; import submissionRoutes from './submissions.js'; import questionRoutes from './questions.js'; +import fileUpload from 'express-fileupload'; +import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js'; +import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router(); @@ -16,13 +25,21 @@ const router = express.Router(); // Route 2: list of object data // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie -router.get('/', getAllLearningObjects); +router.get('/', authenticatedOnly, getAllLearningObjects); + +router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLearningObject); // Parameter: hruid of learning object // Query: language // Route to fetch data of one learning object based on its hruid // Example: http://localhost:3000/learningObject/un_ai7 -router.get('/:hruid', getLearningObject); +router.get('/:hruid', authenticatedOnly, getLearningObject); + +// Parameter: hruid of learning object +// Query: language +// Route to delete a learning object based on its hruid. +// Example: http://localhost:3000/learningObject/un_ai7?language=nl&version=1 +router.delete('/:hruid', onlyAdminsForLearningObject, handleDeleteLearningObject); router.use('/:hruid/submissions', submissionRoutes); @@ -32,12 +49,12 @@ router.use('/:hruid/:version/questions', questionRoutes); // Query: language, version (optional) // Route to fetch the HTML rendering of one learning object based on its hruid. // Example: http://localhost:3000/learningObject/un_ai7/html -router.get('/:hruid/html', getLearningObjectHTML); +router.get('/:hruid/html', authenticatedOnly, getLearningObjectHTML); // Parameter: hruid of learning object, name of attachment. // Query: language, version (optional). // Route to get the raw data of the attachment for one learning object based on its hruid. // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png -router.get('/:hruid/html/:attachmentName', getAttachment); +router.get('/:hruid/html/:attachmentName', authenticatedOnly, getAttachment); export default router; diff --git a/backend/src/routes/learning-paths.ts b/backend/src/routes/learning-paths.ts index efe17312..8f2f1249 100644 --- a/backend/src/routes/learning-paths.ts +++ b/backend/src/routes/learning-paths.ts @@ -1,5 +1,7 @@ import express from 'express'; -import { getLearningPaths } from '../controllers/learning-paths.js'; +import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js'; +import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js'; const router = express.Router(); @@ -22,6 +24,10 @@ const router = express.Router(); // Route to fetch learning paths based on a theme // Example: http://localhost:3000/learningPath?theme=kiks -router.get('/', getLearningPaths); +router.get('/', authenticatedOnly, getLearningPaths); +router.post('/', teachersOnly, postLearningPath); + +router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath); +router.delete('/:hruid/:language', onlyAdminsForLearningPath, deleteLearningPath); export default router; diff --git a/backend/src/routes/questions.ts b/backend/src/routes/questions.ts index 5135c197..6cad3c01 100644 --- a/backend/src/routes/questions.ts +++ b/backend/src/routes/questions.ts @@ -1,20 +1,25 @@ import express from 'express'; import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; import answerRoutes from './answers.js'; +import { authenticatedOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js'; +import { updateAnswerHandler } from '../controllers/answers.js'; +import { onlyAllowAuthor, onlyAllowAuthorRequest, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js'; const router = express.Router({ mergeParams: true }); // Query language // Root endpoint used to search objects -router.get('/', getAllQuestionsHandler); +router.get('/', authenticatedOnly, getAllQuestionsHandler); -router.post('/', createQuestionHandler); - -router.delete('/:seq', deleteQuestionHandler); +router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler); // Information about a question with id -router.get('/:seq', getQuestionHandler); +router.get('/:seq', onlyAllowIfHasAccessToQuestion, getQuestionHandler); + +router.delete('/:seq', studentsOnly, onlyAllowAuthorRequest, deleteQuestionHandler); + +router.put('/:seq', studentsOnly, onlyAllowAuthorRequest, updateAnswerHandler); router.use('/:seq/answers', answerRoutes); diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts index 99d4312c..ae141913 100644 --- a/backend/src/routes/router.ts +++ b/backend/src/routes/router.ts @@ -18,12 +18,30 @@ router.get('/', (_, res: Response) => { }); }); -router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); -router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */); -router.use('/class', classRouter /* #swagger.tags = ['Class'] */); router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); -router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); -router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); -router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); +router.use( + '/class', + classRouter /* #swagger.tags = ['Class'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/learningObject', + learningObjectRoutes /* #swagger.tags = ['Learning Object'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/learningPath', + learningPathRoutes /* #swagger.tags = ['Learning Path'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/student', + studentRouter /* #swagger.tags = ['Student'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/teacher', + teacherRouter /* #swagger.tags = ['Teacher'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/theme', + themeRoutes /* #swagger.tags = ['Theme'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); export default router; diff --git a/backend/src/routes/student-join-requests.ts b/backend/src/routes/student-join-requests.ts index daf79f09..a49984c7 100644 --- a/backend/src/routes/student-join-requests.ts +++ b/backend/src/routes/student-join-requests.ts @@ -5,15 +5,19 @@ import { getStudentRequestHandler, getStudentRequestsHandler, } from '../controllers/students.js'; +import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; +import { onlyAllowStudentHimselfAndTeachersOfClass } from '../middleware/auth/checks/class-auth-checks.js'; + +// Under /:username/joinRequests/ const router = express.Router({ mergeParams: true }); -router.get('/', getStudentRequestsHandler); +router.get('/', preventImpersonation, getStudentRequestsHandler); -router.post('/', createStudentRequestHandler); +router.post('/', preventImpersonation, createStudentRequestHandler); -router.get('/:classId', getStudentRequestHandler); +router.get('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, getStudentRequestHandler); -router.delete('/:classId', deleteClassJoinRequestHandler); +router.delete('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, deleteClassJoinRequestHandler); export default router; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts index 0f5d5349..9ecf4688 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -11,33 +11,37 @@ import { getStudentSubmissionsHandler, } from '../controllers/students.js'; import joinRequestRouter from './student-join-requests.js'; +import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; +import { adminOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router(); // Root endpoint used to search objects -router.get('/', getAllStudentsHandler); +router.get('/', adminOnly, getAllStudentsHandler); -router.post('/', createStudentHandler); +// Users will be created automatically when some resource is created for them. Therefore, this endpoint +// Can only be used by an administrator. +router.post('/', adminOnly, createStudentHandler); -router.delete('/:username', deleteStudentHandler); +router.delete('/:username', preventImpersonation, deleteStudentHandler); // Information about a student's profile -router.get('/:username', getStudentHandler); +router.get('/:username', preventImpersonation, getStudentHandler); // The list of classes a student is in -router.get('/:username/classes', getStudentClassesHandler); +router.get('/:username/classes', preventImpersonation, getStudentClassesHandler); // The list of submissions a student has made -router.get('/:username/submissions', getStudentSubmissionsHandler); +router.get('/:username/submissions', preventImpersonation, getStudentSubmissionsHandler); // The list of assignments a student has -router.get('/:username/assignments', getStudentAssignmentsHandler); +router.get('/:username/assignments', preventImpersonation, getStudentAssignmentsHandler); // The list of groups a student is in -router.get('/:username/groups', getStudentGroupsHandler); +router.get('/:username/groups', preventImpersonation, getStudentGroupsHandler); // A list of questions a user has created -router.get('/:username/questions', getStudentQuestionsHandler); +router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler); router.use('/:username/joinRequests', joinRequestRouter); diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts index fc0aa7c6..88309ce8 100644 --- a/backend/src/routes/submissions.ts +++ b/backend/src/routes/submissions.ts @@ -1,15 +1,15 @@ import express from 'express'; import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; +import { onlyAllowIfHasAccessToSubmission, onlyAllowSubmitter } from '../middleware/auth/checks/submission-checks.js'; +import { adminOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router({ mergeParams: true }); -// Root endpoint used to search objects -router.get('/', getSubmissionsHandler); +router.get('/', adminOnly, getSubmissionsHandler); -router.post('/', createSubmissionHandler); +router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler); -// Information about an submission with id 'id' -router.get('/:id', getSubmissionHandler); +router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler); -router.delete('/:id', deleteSubmissionHandler); +router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler); export default router; diff --git a/backend/src/routes/teacher-invitations.ts b/backend/src/routes/teacher-invitations.ts index 23b943d0..90117088 100644 --- a/backend/src/routes/teacher-invitations.ts +++ b/backend/src/routes/teacher-invitations.ts @@ -6,17 +6,24 @@ import { getInvitationHandler, updateInvitationHandler, } from '../controllers/teacher-invitations.js'; +import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; +import { + onlyAllowReceiverBody, + onlyAllowSender, + onlyAllowSenderBody, + onlyAllowSenderOrReceiver, +} from '../middleware/auth/checks/teacher-invitation-checks.js'; const router = express.Router({ mergeParams: true }); -router.get('/:username', getAllInvitationsHandler); +router.get('/:username', preventImpersonation, getAllInvitationsHandler); -router.get('/:sender/:receiver/:classId', getInvitationHandler); +router.get('/:sender/:receiver/:classId', onlyAllowSenderOrReceiver, getInvitationHandler); -router.post('/', createInvitationHandler); +router.post('/', onlyAllowSenderBody, createInvitationHandler); -router.put('/', updateInvitationHandler); +router.put('/', onlyAllowReceiverBody, updateInvitationHandler); -router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); +router.delete('/:sender/:receiver/:classId', onlyAllowSender, deleteInvitationHandler); export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index b858102d..cb2405aa 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -10,25 +10,27 @@ import { updateStudentJoinRequestHandler, } from '../controllers/teachers.js'; import invitationRouter from './teacher-invitations.js'; - +import { adminOnly } from '../middleware/auth/checks/auth-checks.js'; +import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; +import { onlyAllowTeacherOfClass } from '../middleware/auth/checks/class-auth-checks.js'; const router = express.Router(); // Root endpoint used to search objects -router.get('/', getAllTeachersHandler); +router.get('/', adminOnly, getAllTeachersHandler); -router.post('/', createTeacherHandler); +router.post('/', adminOnly, createTeacherHandler); -router.get('/:username', getTeacherHandler); +router.get('/:username', preventImpersonation, getTeacherHandler); -router.delete('/:username', deleteTeacherHandler); +router.delete('/:username', preventImpersonation, deleteTeacherHandler); -router.get('/:username/classes', getTeacherClassHandler); +router.get('/:username/classes', preventImpersonation, getTeacherClassHandler); -router.get('/:username/students', getTeacherStudentHandler); +router.get('/:username/students', preventImpersonation, getTeacherStudentHandler); -router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); +router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); -router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); +router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); // Invitations to other classes a teacher received router.use('/invitations', invitationRouter); diff --git a/backend/src/routes/themes.ts b/backend/src/routes/themes.ts index b135d44f..6310c2ab 100644 --- a/backend/src/routes/themes.ts +++ b/backend/src/routes/themes.ts @@ -1,14 +1,15 @@ import express from 'express'; import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; +import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router(); // Query: language // Route to fetch list of {key, title, description, image} themes in their respective language -router.get('/', getThemesHandler); +router.get('/', authenticatedOnly, getThemesHandler); // Arg: theme (key) // Route to fetch list of hruids based on theme -router.get('/:theme', getHruidsByThemeHandler); +router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler); export default router; diff --git a/backend/src/services/answers.ts b/backend/src/services/answers.ts index ab603883..7ec5773a 100644 --- a/backend/src/services/answers.ts +++ b/backend/src/services/answers.ts @@ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat return mapToAnswerDTO(answer); } -async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { +export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { const answerRepository = getAnswerRepository(); const question = await fetchQuestion(questionId); const answer = await answerRepository.findAnswer(question, sequenceNumber); diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index b75fe82f..e5026020 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou return group; } +export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise { + const assignment = await fetchAssignment(classId, assignmentNumber); + + const groupRepository = getGroupRepository(); + const groups = await groupRepository.findAllGroupsForAssignment(assignment); + + return groups; +} + export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { const group = await fetchGroup(classId, assignmentNumber, groupNumber); return mapToGroupDTO(group, group.assignment.within); diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index 0b805a56..fb0f70fe 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -109,6 +109,15 @@ const databaseLearningObjectProvider: LearningObjectProvider = { ); return learningObjects.filter((it) => it !== null); }, + + /** + * Returns all learning objects containing the given username as an admin. + */ + async getLearningObjectsAdministratedBy(adminUsername: string): Promise { + const learningObjectRepo = getLearningObjectRepository(); + const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername); + return learningObjects.map((it) => convertLearningObject(it)).filter((it) => it !== null); + }, }; export default databaseLearningObjectProvider; diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts index 1dc0385f..500dad61 100644 --- a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { return html; }, + + /** + * Obtain all learning objects who have the user with the given username as an admin. + */ + async getLearningObjectsAdministratedBy(_adminUsername: string): Promise { + return []; // The dwengo database does not contain any learning objects administrated by users. + }, }; export default dwengoApiLearningObjectProvider; diff --git a/backend/src/services/learning-objects/learning-object-provider.ts b/backend/src/services/learning-objects/learning-object-provider.ts index 14848bc0..69ad268d 100644 --- a/backend/src/services/learning-objects/learning-object-provider.ts +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -20,4 +20,9 @@ export interface LearningObjectProvider { * Obtain a HTML-rendering of the learning object with the given identifier (as a string). */ getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise; + + /** + * Obtain all learning object who have the user with the given username as an admin. + */ + getLearningObjectsAdministratedBy(username: string): Promise; } diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 7b4f47fc..6be10775 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -3,6 +3,11 @@ import { LearningObjectProvider } from './learning-object-provider.js'; import { envVars, getEnvVar } from '../../util/envVars.js'; import databaseLearningObjectProvider from './database-learning-object-provider.js'; import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { getLearningObjectRepository, getTeacherRepository } from '../../data/repositories.js'; +import { processLearningObjectZip } from './learning-object-zip-processing-service.js'; +import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { NotFoundException } from '../../exceptions/not-found-exception.js'; function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { @@ -42,6 +47,66 @@ const learningObjectService = { async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise { return getProvider(id).getLearningObjectHTML(id); }, + + /** + * Obtain all learning objects administrated by the user with the given username. + */ + async getLearningObjectsAdministratedBy(adminUsername: string): Promise { + return databaseLearningObjectProvider.getLearningObjectsAdministratedBy(adminUsername); + }, + + /** + * Store the learning object in the given zip file in the database. + * @param learningObjectPath The path where the uploaded learning object resides. + * @param admins The usernames of the users which should be administrators of the learning object. + */ + async storeLearningObject(learningObjectPath: string, admins: string[]): Promise { + const learningObjectRepository = getLearningObjectRepository(); + const learningObject = await processLearningObjectZip(learningObjectPath); + + if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { + learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid; + } + + // Lookup the admin teachers based on their usernames and add them to the admins of the learning object. + const teacherRepo = getTeacherRepository(); + const adminTeachers = await Promise.all(admins.map(async (it) => teacherRepo.findByUsername(it))); + adminTeachers.forEach((it) => { + if (it !== null) { + learningObject.admins.add(it); + } + }); + + try { + await learningObjectRepository.save(learningObject, { preventOverwrite: true }); + } catch (e: unknown) { + learningObjectRepository.getEntityManager().clear(); + throw e; + } + + return learningObject; + }, + + /** + * Deletes the learning object with the given identifier. + */ + async deleteLearningObject(id: LearningObjectIdentifier): Promise { + const learningObjectRepository = getLearningObjectRepository(); + return await learningObjectRepository.removeByIdentifier(id); + }, + + /** + * Returns a list of the usernames of the administrators of the learning object with the given identifier. + * @throws NotFoundException if the specified learning object was not found in the database. + */ + async getAdmins(id: LearningObjectIdentifier): Promise { + const learningObjectRepo = getLearningObjectRepository(); + const learningObject = await learningObjectRepo.findByIdentifier(id); + if (!learningObject) { + throw new NotFoundException('learningObjectNotFound'); + } + return learningObject.admins.map((admin) => admin.username); + }, }; export default learningObjectService; diff --git a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts new file mode 100644 index 00000000..9b9c4d9f --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts @@ -0,0 +1,119 @@ +import unzipper from 'unzipper'; +import mime from 'mime-types'; +import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { getAttachmentRepository, getLearningObjectRepository } from '../../data/repositories.js'; +import { BadRequestException } from '../../exceptions/bad-request-exception.js'; +import { LearningObjectMetadata } from '@dwengo-1/common/interfaces/learning-content'; +import { DwengoContentType } from './processing/content-type.js'; +import { v4 } from 'uuid'; + +const METADATA_PATH_REGEX = /.*[/^]metadata\.json$/; +const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/; + +/** + * Process an uploaded zip file and construct a LearningObject from its contents. + * @param filePath Path of the zip file to process. + */ +export async function processLearningObjectZip(filePath: string): Promise { + let zip: unzipper.CentralDirectory; + try { + zip = await unzipper.Open.file(filePath); + } catch (_: unknown) { + throw new BadRequestException('invalidZip'); + } + + let metadata: LearningObjectMetadata | undefined = undefined; + const attachments: { name: string; content: Buffer }[] = []; + let content: Buffer | undefined = undefined; + + if (zip.files.length === 0) { + throw new BadRequestException('emptyZip'); + } + + await Promise.all( + zip.files.map(async (file) => { + if (file.type !== 'Directory') { + if (METADATA_PATH_REGEX.test(file.path)) { + metadata = await processMetadataJson(file); + } else if (CONTENT_PATH_REGEX.test(file.path)) { + content = await processFile(file); + } else { + attachments.push({ + name: file.path, + content: await processFile(file), + }); + } + } + }) + ); + + if (!metadata) { + throw new BadRequestException('missingMetadata'); + } + if (!content) { + throw new BadRequestException('missingIndex'); + } + + const learningObject = createLearningObject(metadata, content, attachments); + + return learningObject; +} + +function createLearningObject(metadata: LearningObjectMetadata, content: Buffer, attachments: { name: string; content: Buffer }[]): LearningObject { + const learningObjectRepo = getLearningObjectRepository(); + const attachmentRepo = getAttachmentRepository(); + + const returnValue = { + callbackUrl: metadata.return_value?.callback_url ?? '', + callbackSchema: metadata.return_value?.callback_schema ? JSON.stringify(metadata.return_value.callback_schema) : '', + }; + + if (!metadata.target_ages || metadata.target_ages.length === 0) { + throw new BadRequestException('targetAgesMandatory'); + } + + const learningObject = learningObjectRepo.create({ + admins: [], + available: metadata.available ?? true, + content: content, + contentType: metadata.content_type as DwengoContentType, + copyright: metadata.copyright ?? '', + description: metadata.description ?? '', + educationalGoals: metadata.educational_goals ?? [], + hruid: metadata.hruid, + keywords: metadata.keywords, + language: metadata.language, + license: metadata.license ?? '', + returnValue, + skosConcepts: metadata.skos_concepts ?? [], + teacherExclusive: metadata.teacher_exclusive, + title: metadata.title, + version: metadata.version, + estimatedTime: metadata.estimated_time ?? 1, + targetAges: metadata.target_ages ?? [], + difficulty: metadata.difficulty ?? 1, + uuid: v4(), + }); + const attachmentEntities = attachments.map((it) => + attachmentRepo.create({ + name: it.name, + content: it.content, + mimeType: mime.lookup(it.name) || 'text/plain', + learningObject, + }) + ); + attachmentEntities.forEach((it) => { + learningObject.attachments.add(it); + }); + return learningObject; +} + +async function processMetadataJson(file: unzipper.File): Promise { + const buf = await file.buffer(); + const content = buf.toString(); + return JSON.parse(content); +} + +async function processFile(file: unzipper.File): Promise { + return await file.buffer(); +} diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index eb980fa1..8c82132e 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -16,6 +16,9 @@ import { Language } from '@dwengo-1/common/util/language'; import { Group } from '../../entities/assignments/group.entity'; import { Collection } from '@mikro-orm/core'; import { v4 } from 'uuid'; +import { getLogger } from '../../logging/initalize.js'; + +const logger = getLogger(); /** * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its @@ -38,8 +41,13 @@ async function getLearningObjectsForNodes(nodes: Collection): ) ) ); - if (Array.from(nullableNodesToLearningObjects.values()).some((it) => it === null)) { - throw new Error('At least one of the learning objects on this path could not be found.'); + + // Ignore all learning objects that cannot be found such that the rest of the learning path keeps working. + for (const [key, value] of nullableNodesToLearningObjects) { + if (value === null) { + logger.warn(`Learning object ${key.learningObjectHruid}/${key.language}/${key.version} not found!`); + nullableNodesToLearningObjects.delete(key); + } } return nullableNodesToLearningObjects as Map; } @@ -102,7 +110,15 @@ async function convertNode( !personalizedFor || // If we do not want a personalized learning path, keep all transitions isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible. ) - .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)); + .map((trans, i) => { + try { + return convertTransition(trans, i, nodesToLearningObjects); + } catch (_: unknown) { + logger.error(`Transition could not be resolved: ${JSON.stringify(trans)}`); + return undefined; // Do not crash on invalid transitions, just ignore them so the rest of the learning path keeps working. + } + }) + .filter((it) => it !== undefined); return { _id: learningObject.uuid, language: learningObject.language, @@ -164,6 +180,7 @@ function convertTransition( return { _id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path. default: false, // We don't work with default transitions but retain this for backwards compatibility. + condition: transition.condition, next: { _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility. hruid: transition.next.learningObjectHruid, @@ -198,6 +215,15 @@ const databaseLearningPathProvider: LearningPathProvider = { }; }, + /** + * Returns all the learning paths which have the user with the given username as an administrator. + */ + async getLearningPathsAdministratedBy(adminUsername: string): Promise { + const repo = getLearningPathRepository(); + const paths = await repo.findAllByAdminUsername(adminUsername); + return await Promise.all(paths.map(async (result, index) => convertLearningPath(result, index))); + }, + /** * Search learning paths in the database using the given search string. */ diff --git a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts index 6f2548c0..263bffaf 100644 --- a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -73,6 +73,10 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { } return searchResults ?? []; }, + + async getLearningPathsAdministratedBy(_adminUsername: string) { + return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user. + }, }; export default dwengoApiLearningPathProvider; diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index 086777bd..0cf507ca 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -15,4 +15,9 @@ export interface LearningPathProvider { * Search learning paths in the data source using the given search string. */ searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise; + + /** + * Get all learning paths which have the teacher with the given user as an administrator. + */ + getLearningPathsAdministratedBy(adminUsername: string): Promise; } diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index b20d8f97..56b3be7a 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -1,7 +1,7 @@ import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; import databaseLearningPathProvider from './database-learning-path-provider.js'; import { envVars, getEnvVar } from '../../util/envVars.js'; -import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; +import { LearningObjectNode, LearningPath, LearningPathIdentifier, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; import { Language } from '@dwengo-1/common/util/language'; import { Group } from '../../entities/assignments/group.entity.js'; import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; @@ -12,6 +12,9 @@ import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js'; import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { mapToTeacher } from '../../interfaces/teacher.js'; import { Collection } from '@mikro-orm/core'; +import { NotFoundException } from '../../exceptions/not-found-exception.js'; +import { BadRequestException } from '../../exceptions/bad-request-exception.js'; +import learningObjectService from '../learning-objects/learning-object-service.js'; const userContentPrefix = getEnvVar(envVars.UserContentPrefix); const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; @@ -43,27 +46,24 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L const fromNode = nodes.find( (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version )!; - const transitions = nodeDto.transitions - .map((transDto, i) => { - const toNode = nodes.find( - (it) => - it.learningObjectHruid === transDto.next.hruid && - it.language === transDto.next.language && - it.version === transDto.next.version - ); + const transitions = nodeDto.transitions.map((transDto, i) => { + const toNode = nodes.find( + (it) => + it.learningObjectHruid === transDto.next.hruid && it.language === transDto.next.language && it.version === transDto.next.version + ); - if (toNode) { - return repo.createTransition({ - transitionNumber: i, - node: fromNode, - next: toNode, - condition: transDto.condition ?? 'true', - }); - } - return undefined; - }) - .filter((it) => it) - .map((it) => it!); + if (toNode) { + return repo.createTransition({ + transitionNumber: i, + node: fromNode, + next: toNode, + condition: transDto.condition ?? 'true', + }); + } + throw new BadRequestException( + `Invalid transition destination: ${JSON.stringify(transDto.next)}: This learning object does not exist in this learning path.` + ); + }); fromNode.transitions = new Collection(fromNode, transitions); }); @@ -105,6 +105,14 @@ const learningPathService = { }; }, + /** + * Fetch the learning paths administrated by the teacher with the given username. + */ + async getLearningPathsAdministratedBy(adminUsername: string): Promise { + const providerResponses = await Promise.all(allProviders.map(async (provider) => provider.getLearningPathsAdministratedBy(adminUsername))); + return providerResponses.flat(); + }, + /** * Search learning paths in the data source using the given search string. */ @@ -119,11 +127,67 @@ const learningPathService = { * Add a new learning path to the database. * @param dto Learning path DTO from which the learning path will be created. * @param admins Teachers who should become an admin of the learning path. + * @returns the created learning path. */ - async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { + async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { const repo = getLearningPathRepository(); + + const userContentPrefix = getEnvVar(envVars.UserContentPrefix); + if (!dto.hruid.startsWith(userContentPrefix)) { + dto.hruid = userContentPrefix + dto.hruid; + } + const path = mapToLearningPath(dto, admins); - await repo.save(path, { preventOverwrite: true }); + + // Verify that all specified learning objects actually exist. + const learningObjectsOnPath = await Promise.all( + path.nodes.map(async (node) => + learningObjectService.getLearningObjectById({ + hruid: node.learningObjectHruid, + language: node.language, + version: node.version, + }) + ) + ); + if (learningObjectsOnPath.some((it) => !it)) { + throw new BadRequestException('pathContainsNonExistingLearningObjects'); + } + + try { + await repo.save(path, { preventOverwrite: true }); + } catch (e: unknown) { + repo.getEntityManager().clear(); + throw e; + } + return path; + }, + + /** + * Deletes the learning path with the given identifier from the database. + * @param id Identifier of the learning path to delete. + * @returns the deleted learning path. + */ + async deleteLearningPath(id: LearningPathIdentifier): Promise { + const repo = getLearningPathRepository(); + + const deletedPath = await repo.deleteByHruidAndLanguage(id.hruid, id.language); + if (deletedPath) { + return deletedPath; + } + throw new NotFoundException('No learning path with the given identifier found.'); + }, + + /** + * Returns a list of the usernames of the administrators of the learning path with the given identifier. + * @param id The identifier of the learning path whose admins should be fetched. + */ + async getAdmins(id: LearningPathIdentifier): Promise { + const repo = getLearningPathRepository(); + const path = await repo.findByHruidAndLanguage(id.hruid, id.language); + if (!path) { + throw new NotFoundException('No learning path with the given identifier found.'); + } + return path.admins.map((admin) => admin.username); }, }; diff --git a/backend/src/services/questions.ts b/backend/src/services/questions.ts index 09643cd2..c6d978d8 100644 --- a/backend/src/services/questions.ts +++ b/backend/src/services/questions.ts @@ -13,6 +13,7 @@ import { fetchStudent } from './students.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { FALLBACK_VERSION_NUM } from '../config.js'; import { fetchAssignment } from './assignments.js'; +import { ConflictException } from '../exceptions/conflict-exception.js'; export async function getQuestionsAboutLearningObjectInAssignment( loId: LearningObjectIdentifier, @@ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); + if (!inGroup) { + throw new NotFoundException('Group with id and assignment not found'); + } + + if (!inGroup.members.contains(author)) { + throw new ConflictException('Author is not part of this group'); + } + const question = await questionRepository.createQuestion({ loId, author, - inGroup: inGroup!, + inGroup: inGroup, content, }); diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index 809b23e4..3ccd2dba 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -24,7 +24,8 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ConflictException } from '../exceptions/conflict-exception.js'; -import { Submission } from '../entities/assignments/submission.entity'; +import { Submission } from '../entities/assignments/submission.entity.js'; +import { mapToUsername } from '../interfaces/user.js'; export async function getAllStudents(full: boolean): Promise { const studentRepository = getStudentRepository(); @@ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise user.username); + return users.map(mapToUsername); } export async function fetchStudent(username: string): Promise { @@ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise { const newStudent = mapToStudent(userData); await studentRepository.save(newStudent, { preventOverwrite: true }); - return userData; + return mapToStudentDTO(newStudent); } export async function createOrUpdateStudent(userData: StudentDTO): Promise { diff --git a/backend/src/services/teacher-invitations.ts b/backend/src/services/teacher-invitations.ts index aead8715..0457496f 100644 --- a/backend/src/services/teacher-invitations.ts +++ b/backend/src/services/teacher-invitations.ts @@ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); @@ -26,7 +27,7 @@ export async function getAllTeachers(full: boolean): Promise user.username); + return users.map(mapToUsername); } export async function fetchTeacher(username: string): Promise { @@ -45,7 +46,8 @@ export async function getTeacher(username: string): Promise { return mapToTeacherDTO(user); } -export async function createTeacher(userData: TeacherDTO): Promise { +// TODO update parameter +export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); const newTeacher = mapToTeacher(userData); @@ -98,7 +100,9 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro const classIds: string[] = classes.map((cls) => cls.id); - const students: StudentDTO[] = (await Promise.all(classIds.map(async (username) => await getClassStudentsDTO(username)))).flat(); + const students: StudentDTO[] = (await Promise.all(classIds.map(async (classId) => await getClassStudentsDTO(classId)))) + .flat() + .filter((student, index, self) => self.findIndex((s) => s.username === student.username) === index); if (full) { return students; diff --git a/backend/tests/controllers/teachers.test.ts b/backend/tests/controllers/teachers.test.ts index 720365a4..fcf80d90 100644 --- a/backend/tests/controllers/teachers.test.ts +++ b/backend/tests/controllers/teachers.test.ts @@ -15,7 +15,6 @@ import { import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { getStudentRequestsHandler } from '../../src/controllers/students.js'; -import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { getClassHandler } from '../../src/controllers/classes'; import { getClass02 } from '../test_assets/classes/classes.testdata'; @@ -97,7 +96,7 @@ describe('Teacher controllers', () => { }); it('Teacher list', async () => { - req = { query: { full: 'true' } }; + req = { query: { full: 'false' } }; await getAllTeachersHandler(req as Request, res as Response); @@ -105,8 +104,7 @@ describe('Teacher controllers', () => { const result = jsonMock.mock.lastCall?.[0]; - const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); - expect(teacherUsernames).toContain('testleerkracht1'); + expect(result.teachers).toContain('testleerkracht1'); expect(result.teachers).toHaveLength(5); }); diff --git a/common/src/interfaces/learning-content.ts b/common/src/interfaces/learning-content.ts index 582e0086..453d1bac 100644 --- a/common/src/interfaces/learning-content.ts +++ b/common/src/interfaces/learning-content.ts @@ -25,8 +25,8 @@ export interface LearningObjectNode { language: Language; start_node?: boolean; transitions: Transition[]; - created_at: string; - updatedAt: string; + created_at?: string; + updatedAt?: string; done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. } @@ -79,6 +79,8 @@ export interface LearningObjectMetadata { target_ages: number[]; content_type: string; // Markdown, image, etc. content_location?: string; + copyright?: string; + license?: string; skos_concepts?: string[]; return_value?: ReturnValue; } diff --git a/common/src/interfaces/question.ts b/common/src/interfaces/question.ts index 172d14b7..2d681fc0 100644 --- a/common/src/interfaces/question.ts +++ b/common/src/interfaces/question.ts @@ -13,8 +13,8 @@ export interface QuestionDTO { export interface QuestionData { author?: string; - content: string; inGroup: GroupDTO; + content: string; } export interface QuestionId { diff --git a/common/src/util/account-types.ts b/common/src/util/account-types.ts new file mode 100644 index 00000000..f0957019 --- /dev/null +++ b/common/src/util/account-types.ts @@ -0,0 +1,4 @@ +export enum AccountType { + Student = 'student', + Teacher = 'teacher', +} diff --git a/compose.production.yml b/compose.production.yml index 5bfa8108..544e527f 100644 --- a/compose.production.yml +++ b/compose.production.yml @@ -67,8 +67,6 @@ services: - 'traefik.enable=true' - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' - 'traefik.http.services.idp.loadbalancer.server.port=7080' - - 'traefik.http.routers.block-admin.rule=PathPrefix(`/idp/admin`)' - - 'traefik.http.routers.block-admin.service=web' depends_on: - keycloak-db volumes: @@ -95,6 +93,9 @@ services: - '80:80/tcp' - '443:443/tcp' command: + # Enable web UI + - '--api=true' + # Add Docker provider - '--providers.docker=true' - '--providers.docker.exposedbydefault=false' @@ -115,6 +116,17 @@ services: - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' - '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be' - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' + labels: + # BasicAuth middleware + # To create a user:password pair, the following command can be used: + # echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g + - 'traefik.http.middlewares.protected-sub-path.basicauth.users=dwengo.org:$$apr1$$FdALqAjI$$7ZhPq0I/qEQ6k3OYqxJKZ1' + # Proxying + - 'traefik.enable=true' + - 'traefik.http.routers.proxy.middlewares=protected-sub-path' + - 'traefik.http.routers.proxy.service=api@internal' + - 'traefik.http.routers.proxy.rule=PathPrefix(`/proxy`)' + - 'traefik.http.services.proxy.loadbalancer.server.port=8080' restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -137,8 +149,10 @@ services: dashboards: image: grafana/grafana:latest - ports: - - '9002:3000' + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)' + - 'traefik.http.services.graphs.loadbalancer.server.port=3000' restart: unless-stopped volumes: - dwengo_grafana_data:/var/lib/grafana diff --git a/compose.staging.yml b/compose.staging.yml index 83977cd9..a404f67c 100644 --- a/compose.staging.yml +++ b/compose.staging.yml @@ -60,6 +60,13 @@ services: # Add web entrypoint - '--entrypoints.web.address=:80/tcp' + + # Proxying the web UI on a sub-path + - '--api.basePath=/proxy' + labels: + - 'traefik.http.routers.proxy.service=api@internal' + - 'traefik.http.routers.proxy.rule=PathPrefix(`/proxy`)' + - 'traefik.http.services.proxy.loadbalancer.server.port=8080' ports: - '9000:8080' - '80:80/tcp' @@ -82,8 +89,12 @@ services: image: grafana/grafana:latest ports: - '9002:3000' + labels: + - 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)' + - 'traefik.http.services.graphs.loadbalancer.server.port=3000' volumes: - dwengo_grafana_data:/var/lib/grafana + - ./config/grafana/grafana.ini:/etc/grafana/grafana.ini restart: unless-stopped caching: diff --git a/config/grafana/grafana.ini b/config/grafana/grafana.ini new file mode 100644 index 00000000..7421cb3f --- /dev/null +++ b/config/grafana/grafana.ini @@ -0,0 +1,8 @@ +[server] + +root_url = http://localhost:3000/graphs +serve_from_sub_path = true + +[security] + +admin_user = dwengo.org diff --git a/docs/api/generate.ts b/docs/api/generate.ts index 796369d1..07523a32 100644 --- a/docs/api/generate.ts +++ b/docs/api/generate.ts @@ -26,7 +26,59 @@ const doc = { ], components: { securitySchemes: { - student: { + studentDev: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost:7080/realms/student/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + teacherDev: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost:7080/realms/teacher/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + studentStaging: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost/idp/realms/student/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + teacherStaging: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost/idp/realms/teacher/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + studentProduction: { type: 'oauth2', flows: { implicit: { @@ -39,7 +91,7 @@ const doc = { }, }, }, - teacher: { + teacherProduction: { type: 'oauth2', flows: { implicit: { diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9cbb61ea..1ddb8dc0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,22 +3,28 @@ FROM node:22 AS build-stage # install simple http server for serving static content RUN npm install -g http-server -WORKDIR /app +WORKDIR /app/dwengo # Install dependencies COPY package*.json ./ COPY ./frontend/package.json ./frontend/ +# Frontend depends on common +COPY common/package.json ./common/ RUN npm install --silent # Build the frontend # Root tsconfig.json -COPY tsconfig.json ./ -COPY assets ./assets/ +COPY tsconfig.json tsconfig.build.json ./ -WORKDIR /app/frontend +COPY assets ./assets +COPY common ./common + +RUN npm run build --workspace=common + +WORKDIR /app/dwengo/frontend COPY frontend ./ @@ -28,8 +34,8 @@ FROM nginx:stable AS production-stage COPY config/nginx/nginx.conf /etc/nginx/nginx.conf -COPY --from=build-stage /app/assets /usr/share/nginx/html/assets -COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html +COPY --from=build-stage /app/dwengo/assets /usr/share/nginx/html/assets +COPY --from=build-stage /app/dwengo/frontend/dist /usr/share/nginx/html EXPOSE 8080 diff --git a/frontend/e2e/assignments.spec.ts b/frontend/e2e/assignments.spec.ts new file mode 100644 index 00000000..1279ffde --- /dev/null +++ b/frontend/e2e/assignments.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test"; + +test("Teacher can create new assignment", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to assignments + await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); + await expect(page.getByRole("heading", { name: "Assignments" })).toBeVisible(); + await expect(page.getByRole("button", { name: "New Assignment" })).toBeVisible(); + + // Create new assignment + await page.getByRole("button", { name: "New Assignment" }).click(); + await expect(page.getByRole("button", { name: "submit" })).toBeVisible(); + await expect(page.getByRole("link", { name: "cancel" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Title Title" }).fill("Assignment test 1"); + await page.getByRole("textbox", { name: "Select a learning path Select" }).click(); + await page.getByText("Using notebooks").click(); + await page.getByRole("textbox", { name: "Pick a class Pick a class" }).click(); + await page.getByText("class01").click(); + await page.getByRole("textbox", { name: "Select Deadline Select" }).fill("2099-01-01T12:34"); + await page.getByRole("textbox", { name: "Description Description" }).fill("Assignment description"); + + await page.getByRole("button", { name: "submit" }).click(); + + await expect(page.getByText("Assignment test")).toBeVisible(); + await expect(page.getByRole("main").getByRole("button").first()).toBeVisible(); + await expect(page.getByRole("main")).toContainText("Assignment test 1"); + await expect(page.getByRole("link", { name: "Learning path" })).toBeVisible(); + await expect(page.getByRole("main")).toContainText("Assignment description"); +}); + +test("Student can see list of assignments", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "student" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to assignments + await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); + await expect(page.getByRole("heading", { name: "Assignments" })).toBeVisible(); + await expect(page.getByText("dire straits")).toBeVisible(); + await expect(page.locator(".button-row > .v-btn").first()).toBeVisible(); + await expect(page.getByText("Class: class01").first()).toBeVisible(); +}); + +test("Student can see assignment details", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "student" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to assignments + await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); + await expect(page.getByText("Assignment: Conditional")).toBeVisible(); + await expect(page.locator("div:nth-child(2) > .v-card > .button-row > .v-btn")).toBeVisible(); + + // View assignment details + await page.locator("div:nth-child(2) > .v-card > .button-row > .v-btn").click(); + await expect(page.getByText("Assignment: Conditional")).toBeVisible(); + await expect(page.getByRole("link", { name: "Learning path" })).toBeVisible(); + await expect(page.getByRole("progressbar").locator("div").first()).toBeVisible(); +}); diff --git a/frontend/e2e/basic-learning.spec.ts b/frontend/e2e/basic-learning.spec.ts index f7438454..03be6e10 100644 --- a/frontend/e2e/basic-learning.spec.ts +++ b/frontend/e2e/basic-learning.spec.ts @@ -1,8 +1,16 @@ -import { test, expect } from "./fixtures.js"; +import { test, expect } from "@playwright/test"; test("Users can filter", async ({ page }) => { - await page.goto("/user"); + await page.goto("/"); + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Filter await page.getByRole("combobox").filter({ hasText: "Select a themeAll" }).locator("i").click(); await page.getByText("Nature and climate").click(); await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click(); diff --git a/frontend/e2e/basic-learning.ts b/frontend/e2e/basic-learning.ts deleted file mode 100644 index 157debb0..00000000 --- a/frontend/e2e/basic-learning.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { test, expect } from "./fixtures.js"; - -test("myTest", async ({ page }) => { - await expect(page).toHaveURL("/"); -}); diff --git a/frontend/e2e/class.spec.ts b/frontend/e2e/class.spec.ts new file mode 100644 index 00000000..b3ef29b9 --- /dev/null +++ b/frontend/e2e/class.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from "@playwright/test"; + +test("Teacher can create a class", async ({ page }) => { + const className = "DeTijdLoze"; + + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to class + await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + + // Check if the class page is visible + await expect(page.getByRole("heading", { name: "Classes" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "classname classname" })).toBeVisible(); + await expect(page.getByRole("button", { name: "create" })).toBeVisible(); + + // Create a class + await page.getByRole("textbox", { name: "classname classname" }).click(); + await page.getByRole("textbox", { name: "classname classname" }).fill(className); + await page.getByRole("button", { name: "create" }).click(); + + // Check if the class is created + await expect(page.getByRole("dialog").getByText("code")).toBeVisible(); + await expect(page.getByRole("button", { name: "close" })).toBeVisible(); +}); + +test("Teacher can share a class by code", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to classes + await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + + await expect(page.getByRole("row", { name: "class01" }).locator("i").nth(1)).toBeVisible(); + await page.getByRole("row", { name: "class01" }).locator("i").nth(1).click(); + await expect(page.getByRole("button").filter({ hasText: /^$/ }).nth(2)).toBeVisible(); + await expect(page.getByRole("button").filter({ hasText: /^$/ }).nth(3)).toBeVisible(); + await page.getByRole("button").filter({ hasText: /^$/ }).nth(3).click(); + await expect(page.getByText("copied!")).toBeVisible(); + await page.getByRole("button", { name: "close" }).click(); +}); + +test("Student can join class by code", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "student" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to class + await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + + // Check if the class page is visible + await expect(page.getByRole("heading", { name: "Classes" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Join class" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "CODE CODE" })).toBeVisible(); + await expect(page.getByRole("button", { name: "submit" })).toBeVisible(); + + // Join a class + await page.getByRole("textbox", { name: "CODE CODE" }).click(); + await page.getByRole("textbox", { name: "CODE CODE" }).fill("X2J9QT"); + await page.getByRole("button", { name: "submit" }).click(); +}); + +test("Teacher can remove student from class", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + await expect(page.getByRole("link", { name: "class01" })).toBeVisible(); + await expect(page.locator("#app")).toContainText("8"); + await page.getByRole("link", { name: "class01" }).click(); + await expect(page.getByRole("cell", { name: "Kurt Cobain" })).toBeVisible(); + await expect(page.getByRole("row", { name: "Kurt Cobain remove" }).getByRole("button")).toBeVisible(); + await page.getByRole("row", { name: "Kurt Cobain remove" }).getByRole("button").click(); + await expect(page.getByText("Are you sure?")).toBeVisible(); + await expect(page.getByRole("button", { name: "cancel" })).toBeVisible(); + await expect(page.getByRole("button", { name: "yes" })).toBeVisible(); + await page.getByRole("button", { name: "yes" }).click(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + await expect(page.locator("#app")).toContainText("7"); +}); diff --git a/frontend/package.json b/frontend/package.json index 0826edae..c9bea614 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,10 +17,12 @@ "test:e2e": "playwright test" }, "dependencies": { + "@dwengo-1/common": "^0.2.0", "@tanstack/react-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0", "@vueuse/core": "^13.1.0", "axios": "^1.8.2", + "json-editor-vue": "^0.18.1", "oidc-client-ts": "^3.1.0", "rollup": "^4.40.0", "uuid": "^11.1.0", diff --git a/frontend/src/components/BrowseThemes.vue b/frontend/src/components/BrowseThemes.vue index 7b1971d4..738ea7fb 100644 --- a/frontend/src/components/BrowseThemes.vue +++ b/frontend/src/components/BrowseThemes.vue @@ -5,6 +5,7 @@ import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; import { useThemeQuery } from "@/queries/themes.ts"; import type { Theme } from "@/data-objects/theme.ts"; + import authService from "@/services/auth/auth-service"; const props = defineProps({ selectedTheme: { type: String, required: true }, @@ -33,6 +34,8 @@ cards.value = themes; } }); + + const isTeacher = computed(() => authService.authState.activeRole === "teacher"); - + diff --git a/frontend/src/components/LearningPathsGrid.vue b/frontend/src/components/LearningPathsGrid.vue index 865c7166..8df08a00 100644 --- a/frontend/src/components/LearningPathsGrid.vue +++ b/frontend/src/components/LearningPathsGrid.vue @@ -53,9 +53,9 @@ white-space: normal; } .results-grid { - margin: 20px; + margin: 20px auto; display: flex; - align-items: stretch; + justify-content: center; gap: 20px; flex-wrap: wrap; } diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index a58be2f8..88b757b7 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -7,13 +7,16 @@ // Import assets import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; + import { useLocale } from "vuetify"; const { t, locale } = useI18n(); + const { current: vuetifyLocale } = useLocale(); const role = auth.authState.activeRole; const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable const name: string = auth.authState.user!.profile.name!; + const username = auth.authState.user!.profile.preferred_username!; const email = auth.authState.user!.profile.email; const initials: string = name .split(" ") @@ -31,6 +34,7 @@ // Logic to change the language of the website to the selected language function changeLanguage(langCode: string): void { locale.value = langCode; + vuetifyLocale.value = langCode; localStorage.setItem("user-lang", langCode); } @@ -180,10 +184,15 @@
- - {{ initials }} + + {{ initials }}

{{ name }}

+

{{ username }}

{{ email }}

diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index 64f2363d..b2b0da01 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -37,6 +37,33 @@ export abstract class BaseController { return response.data; } + /** + * Sends a POST-request with a form-data body with the given file. + * + * @param path Relative path in the api to send the request to. + * @param formFieldName The name of the form field in which the file should be. + * @param file The file to upload. + * @param queryParams The query parameters. + * @returns The response the POST request generated. + */ + protected async postFile( + path: string, + formFieldName: string, + file: File, + queryParams?: QueryParams, + ): Promise { + const formData = new FormData(); + formData.append(formFieldName, file); + const response = await apiClient.post(this.absolutePathFor(path), formData, { + params: queryParams, + headers: { + "Content-Type": "multipart/form-data", + }, + }); + BaseController.assertSuccessResponse(response); + return response.data; + } + protected async delete(path: string, queryParams?: QueryParams): Promise { const response = await apiClient.delete(this.absolutePathFor(path), { params: queryParams }); BaseController.assertSuccessResponse(response); diff --git a/frontend/src/controllers/learning-objects.ts b/frontend/src/controllers/learning-objects.ts index d62ba1f4..a9ecf22f 100644 --- a/frontend/src/controllers/learning-objects.ts +++ b/frontend/src/controllers/learning-objects.ts @@ -14,4 +14,16 @@ export class LearningObjectController extends BaseController { async getHTML(hruid: string, language: Language, version: number): Promise { return this.get(`/${hruid}/html`, { language, version }, "document"); } + + async getAllAdministratedBy(admin: string): Promise { + return this.get("/", { admin }); + } + + async upload(learningObjectZip: File): Promise { + return this.postFile("/", "learningObject", learningObjectZip); + } + + async deleteLearningObject(hruid: string, language: Language, version: number): Promise { + return this.delete(`/${hruid}`, { language, version }); + } } diff --git a/frontend/src/controllers/learning-paths.ts b/frontend/src/controllers/learning-paths.ts index 09a30feb..1a1b0cc5 100644 --- a/frontend/src/controllers/learning-paths.ts +++ b/frontend/src/controllers/learning-paths.ts @@ -1,8 +1,8 @@ import { BaseController } from "@/controllers/base-controller.ts"; -import { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { Language } from "@/data-objects/language.ts"; -import { single } from "@/utils/response-assertions.ts"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; +import { LearningPath } from "@/data-objects/learning-paths/learning-path"; +import { NotFoundException } from "@/exception/not-found-exception"; +import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; export class LearningPathController extends BaseController { constructor() { @@ -24,7 +24,10 @@ export class LearningPathController extends BaseController { assignmentNo: forGroup?.assignmentNo, classId: forGroup?.classId, }); - return LearningPath.fromDTO(single(dtos)); + if (dtos.length === 0) { + throw new NotFoundException("learningPathNotFound"); + } + return LearningPath.fromDTO(dtos[0]); } async getAllByThemeAndLanguage(theme: string, language: Language): Promise { const dtos = await this.get("/", { theme, language }); @@ -36,4 +39,20 @@ export class LearningPathController extends BaseController { const dtos = await this.get("/", query); return dtos.map((dto) => LearningPath.fromDTO(dto)); } + + async getAllByAdminRaw(admin: string): Promise { + return await this.get("/", { admin }); + } + + async postLearningPath(learningPath: Partial): Promise { + return await this.post("/", learningPath); + } + + async putLearningPath(learningPath: Partial): Promise { + return await this.put(`/${learningPath.hruid}/${learningPath.language}`, learningPath); + } + + async deleteLearningPath(hruid: string, language: string): Promise { + return await this.delete(`/${hruid}/${language}`); + } } diff --git a/frontend/src/data-objects/learning-paths/learning-path-node.ts b/frontend/src/data-objects/learning-paths/learning-path-node.ts index 99bac8db..72737f97 100644 --- a/frontend/src/data-objects/learning-paths/learning-path-node.ts +++ b/frontend/src/data-objects/learning-paths/learning-path-node.ts @@ -1,5 +1,5 @@ import type { Language } from "@/data-objects/language.ts"; -import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.ts"; +import type { LearningObjectNode as LearningPathNodeDTO } from "@dwengo-1/common/interfaces/learning-content"; export class LearningPathNode { public readonly learningobjectHruid: string; @@ -14,7 +14,7 @@ export class LearningPathNode { learningobjectHruid: string; version: number; language: Language; - transitions: { next: LearningPathNode; default: boolean }[]; + transitions: { next: LearningPathNode; default?: boolean }[]; createdAt: Date; updatedAt: Date; done?: boolean; @@ -22,7 +22,7 @@ export class LearningPathNode { this.learningobjectHruid = options.learningobjectHruid; this.version = options.version; this.language = options.language; - this.transitions = options.transitions; + this.transitions = options.transitions.map((it) => ({ next: it.next, default: it.default ?? false })); this.createdAt = options.createdAt; this.updatedAt = options.updatedAt; this.done = options.done || false; @@ -50,8 +50,8 @@ export class LearningPathNode { return undefined; }) .filter((it) => it !== undefined), - createdAt: new Date(dto.created_at), - updatedAt: new Date(dto.updatedAt), + createdAt: dto.created_at ? new Date(dto.created_at) : new Date(), + updatedAt: dto.updatedAt ? new Date(dto.updatedAt) : new Date(), done: dto.done, }); } diff --git a/frontend/src/data-objects/learning-paths/learning-path.ts b/frontend/src/data-objects/learning-paths/learning-path.ts index d764d123..880aad85 100644 --- a/frontend/src/data-objects/learning-paths/learning-path.ts +++ b/frontend/src/data-objects/learning-paths/learning-path.ts @@ -1,6 +1,6 @@ import type { Language } from "@/data-objects/language.ts"; import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; +import type { LearningObjectNode, LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; export interface LearningPathNodeDTO { _id: string; @@ -77,20 +77,26 @@ export class LearningPath { hruid: dto.hruid, title: dto.title, description: dto.description, - amountOfNodes: dto.num_nodes, - amountOfNodesLeft: dto.num_nodes_left, + amountOfNodes: dto.num_nodes ?? dto.nodes.length, + amountOfNodesLeft: dto.num_nodes_left ?? dto.nodes.length, keywords: dto.keywords.split(" "), - targetAges: { min: dto.min_age, max: dto.max_age }, + targetAges: { + min: dto.min_age ?? NaN, + max: dto.max_age ?? NaN, + }, startNode: LearningPathNode.fromDTOAndOtherNodes(LearningPath.getStartNode(dto), dto.nodes), image: dto.image, }); } - static getStartNode(dto: LearningPathDTO): LearningPathNodeDTO { + static getStartNode(dto: LearningPathDTO): LearningObjectNode { const startNodeDtos = dto.nodes.filter((it) => it.start_node === true); if (startNodeDtos.length < 1) { // The learning path has no starting node -> use the first node. - return dto.nodes[0]; + if (dto.nodes.length > 0) { + return dto.nodes[0]; + } + throw new Error("emptyLearningPath"); } // The learning path has 1 or more starting nodes -> use the first start node. return startNodeDtos[0]; } diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index 40a552f5..b187d248 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -135,5 +135,36 @@ "valid-username": "Bitte geben Sie einen gültigen Benutzernamen ein", "creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es erneut", "no-assignments": "Derzeit gibt es keine Zuweisungen.", - "deadline": "deadline" + "deadline": "deadline", + "learningObjects": "Lernobjekte", + "learningPaths": "Lernpfade", + "hruid": "HRUID", + "language": "Sprache", + "version": "Version", + "previewFor": "Vorschau für ", + "upload": "Hochladen", + "learningObjectUploadTitle": "Lernobjekt hochladen", + "uploadFailed": "Hochladen fehlgeschlagen", + "invalidZip": "Dies ist keine gültige ZIP-Datei.", + "emptyZip": "Diese ZIP-Datei ist leer.", + "missingMetadata": "Dieses Lernobjekt enthält keine metadata.json-Datei.", + "missingContent": "Dieses Lernobjekt enthält keine content.*-Datei.", + "open": "öffnen", + "editLearningPath": "Lernpfad bearbeiten", + "newLearningPath": "Neuen Lernpfad erstellen", + "saveChanges": "Änderungen speichern", + "newLearningObject": "Lernobjekt hochladen", + "confirmDialogTitle": "Bitte bestätigen", + "learningPathDeleteQuery": "Möchten Sie diesen Lernpfad wirklich löschen?", + "learningObjectDeleteQuery": "Möchten Sie dieses Lernobjekt wirklich löschen?", + "learningPathCantModifyId": "Der HRUID oder die Sprache eines Lernpfads kann nicht geändert werden.", + "error": "Fehler", + "ownLearningContentTitle": "Eigene Lerninhalte", + "ownLearningContentDescription": "Erstellen und verwalten Sie eigene Lernobjekte und Lernpfade. Nur für fortgeschrittene Nutzer.", + "learningPathNotFound": "Dieser Lernpfad konnte nicht gefunden werden.", + "emptyLearningPath": "Dieser Lernpfad enthält keine Lernobjekte.", + "pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.", + "targetAgesMandatory": "Zielalter müssen angegeben werden.", + "hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)", + "hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index 70ab65c1..0754fc5a 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -135,5 +135,36 @@ "valid-username": "please enter a valid username", "creationFailed": "creation failed, please try again", "no-assignments": "There are currently no assignments.", - "deadline": "deadline" + "deadline": "deadline", + "learningObjects": "Learning objects", + "learningPaths": "Learning paths", + "hruid": "HRUID", + "language": "Language", + "version": "Version", + "previewFor": "Preview for ", + "upload": "Upload", + "learningObjectUploadTitle": "Upload a learning object", + "uploadFailed": "Upload failed", + "invalidZip": "This is not a valid zip file.", + "emptyZip": "This zip file is empty", + "missingMetadata": "This learning object is missing a metadata.json file.", + "missingContent": "This learning object is missing a content.* file.", + "open": "open", + "editLearningPath": "Edit learning path", + "newLearningPath": "Create a new learning path", + "saveChanges": "Save changes", + "newLearningObject": "Upload learning object", + "confirmDialogTitle": "Please confirm", + "learningPathDeleteQuery": "Are you sure you want to delete this learning path?", + "learningObjectDeleteQuery": "Are you sure you want to delete this learning object?", + "learningPathCantModifyId": "The HRUID or language of a learning path cannot be modified.", + "error": "Error", + "ownLearningContentTitle": "Own learning content", + "ownLearningContentDescription": "Create and administrate your own learning objects and learning paths. For advanced users only.", + "learningPathNotFound": "This learning path could not be found.", + "emptyLearningPath": "This learning path does not contain any learning objects.", + "pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.", + "targetAgesMandatory": "Target ages must be specified.", + "hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)", + "hintKeywordsSeparatedBySpaces": "Keywords separated by spaces" } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index e3ba87f7..2714296c 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -136,5 +136,36 @@ "valid-username": "veuillez entrer un nom d'utilisateur valide", "creationFailed": "échec de la création, veuillez réessayer", "no-assignments": "Il n'y a actuellement aucun travail.", - "deadline": "délai" + "deadline": "délai", + "learningObjects": "Objets d’apprentissage", + "learningPaths": "Parcours d’apprentissage", + "hruid": "HRUID", + "language": "Langue", + "version": "Version", + "previewFor": "Aperçu de ", + "upload": "Téléverser", + "learningObjectUploadTitle": "Téléverser un objet d’apprentissage", + "uploadFailed": "Échec du téléversement", + "invalidZip": "Ce n’est pas un fichier ZIP valide.", + "emptyZip": "Ce fichier ZIP est vide.", + "missingMetadata": "Il manque un fichier metadata.json à cet objet d’apprentissage.", + "missingContent": "Il manque un fichier content.* à cet objet d’apprentissage.", + "open": "ouvrir", + "editLearningPath": "Modifier le parcours", + "newLearningPath": "Créer un nouveau parcours", + "saveChanges": "Enregistrer les modifications", + "newLearningObject": "Téléverser un objet d’apprentissage", + "confirmDialogTitle": "Veuillez confirmer", + "learningPathDeleteQuery": "Voulez-vous vraiment supprimer ce parcours d’apprentissage ?", + "learningObjectDeleteQuery": "Voulez-vous vraiment supprimer cet objet d’apprentissage ?", + "learningPathCantModifyId": "Le HRUID ou la langue d’un parcours ne peuvent pas être modifiés.", + "error": "Erreur", + "ownLearningContentTitle": "Contenu d’apprentissage personnel", + "ownLearningContentDescription": "Créez et gérez vos propres objets et parcours d’apprentissage. Réservé aux utilisateurs avancés.", + "learningPathNotFound": "Ce parcours d'apprentissage est introuvable.", + "emptyLearningPath": "Ce parcours d'apprentissage ne contient aucun objet d'apprentissage.", + "pathContainsNonExistingLearningObjects": "Au moins un des objets d’apprentissage référencés dans ce chemin n’existe pas.", + "targetAgesMandatory": "Les âges cibles doivent être spécifiés.", + "hintRemoveIfUnconditionalTransition": "(supprimer ceci s’il s’agit d’une transition inconditionnelle)", + "hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces" } diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 18db9fba..c4409d1b 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -135,5 +135,36 @@ "valid-username": "voer een geldige gebruikersnaam in", "creationFailed": "aanmaak mislukt, probeer het opnieuw", "no-assignments": "Er zijn momenteel geen opdrachten.", - "deadline": "deadline" + "deadline": "deadline", + "learningObjects": "Leerobjecten", + "learningPaths": "Leerpaden", + "hruid": "HRUID", + "language": "Taal", + "version": "Versie", + "previewFor": "Voorbeeld van ", + "upload": "Uploaden", + "learningObjectUploadTitle": "Leerobject uploaden", + "uploadFailed": "Upload mislukt", + "invalidZip": "Dit is geen geldig zipbestand.", + "emptyZip": "Dit zipbestand is leeg.", + "missingMetadata": "Dit leerobject mist een metadata.json-bestand.", + "missingContent": "Dit leerobject mist een content.*-bestand.", + "open": "openen", + "editLearningPath": "Leerpad bewerken", + "newLearningPath": "Nieuw leerpad aanmaken", + "saveChanges": "Wijzigingen opslaan", + "newLearningObject": "Leerobject uploaden", + "confirmDialogTitle": "Bevestig alstublieft", + "learningPathDeleteQuery": "Weet u zeker dat u dit leerpad wilt verwijderen?", + "learningObjectDeleteQuery": "Weet u zeker dat u dit leerobject wilt verwijderen?", + "learningPathCantModifyId": "De HRUID of taal van een leerpad kan niet worden gewijzigd.", + "error": "Fout", + "ownLearningContentTitle": "Eigen leerinhoud", + "ownLearningContentDescription": "Maak en beheer je eigen leerobjecten en leerpads. Alleen voor gevorderde gebruikers.", + "learningPathNotFound": "Dit leerpad kon niet gevonden worden.", + "emptyLearningPath": "Dit leerpad bevat geen leerobjecten.", + "pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.", + "targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.", + "hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)", + "hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties" } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b5315634..3e825557 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,15 +7,20 @@ import * as components from "vuetify/components"; import * as directives from "vuetify/directives"; import i18n from "./i18n/i18n.ts"; +// JSON-editor +import JsonEditorVue from "json-editor-vue"; + // Components import App from "./App.vue"; import router from "./router"; import { aliases, mdi } from "vuetify/iconsets/mdi"; import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; +import { de, en, fr, nl } from "vuetify/locale"; const app = createApp(App); app.use(router); +app.use(JsonEditorVue, {}); const link = document.createElement("link"); link.rel = "stylesheet"; @@ -32,6 +37,11 @@ const vuetify = createVuetify({ mdi, }, }, + locale: { + locale: i18n.global.locale, + fallback: "en", + messages: { nl, en, de, fr }, + }, }); const queryClient = new QueryClient({ diff --git a/frontend/src/queries/classes.ts b/frontend/src/queries/classes.ts index dca92230..6c452f10 100644 --- a/frontend/src/queries/classes.ts +++ b/frontend/src/queries/classes.ts @@ -15,6 +15,7 @@ import { invalidateAllGroupKeys } from "./groups"; import { invalidateAllSubmissionKeys } from "./submissions"; import type { TeachersResponse } from "@/controllers/teachers"; import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; +import { studentClassesQueryKey } from "@/queries/students.ts"; const classController = new ClassController(); @@ -171,6 +172,8 @@ export function useClassDeleteStudentMutation(): UseMutationReturnType< await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) }); await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); + await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, false) }); + await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, true) }); }, }); } diff --git a/frontend/src/queries/learning-objects.ts b/frontend/src/queries/learning-objects.ts index 35ed7ae4..20eb210c 100644 --- a/frontend/src/queries/learning-objects.ts +++ b/frontend/src/queries/learning-objects.ts @@ -1,9 +1,16 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; -import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; import { getLearningObjectController } from "@/controllers/controllers.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { AxiosError } from "axios"; export const LEARNING_OBJECT_KEY = "learningObject"; const learningObjectController = getLearningObjectController(); @@ -24,15 +31,15 @@ export function useLearningObjectMetadataQuery( } export function useLearningObjectHTMLQuery( - hruid: MaybeRefOrGetter, - language: MaybeRefOrGetter, - version: MaybeRefOrGetter, + hruid: MaybeRefOrGetter, + language: MaybeRefOrGetter, + version: MaybeRefOrGetter, ): UseQueryReturnType { return useQuery({ queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version], queryFn: async () => { const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; - return learningObjectController.getHTML(hruidVal, languageVal, versionVal); + return learningObjectController.getHTML(hruidVal!, languageVal!, versionVal!); }, enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), }); @@ -55,3 +62,49 @@ export function useLearningObjectListForPathQuery( enabled: () => Boolean(toValue(learningPath)), }); } + +export function useLearningObjectListForAdminQuery( + admin: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_OBJECT_KEY, "forAdmin", admin], + queryFn: async () => { + const adminVal = toValue(admin); + return await learningObjectController.getAllAdministratedBy(adminVal!); + }, + enabled: () => toValue(admin) !== undefined, + }); +} + +export function useUploadLearningObjectMutation(): UseMutationReturnType< + LearningObject, + AxiosError, + { learningObjectZip: File }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningObjectZip }) => await learningObjectController.upload(learningObjectZip), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [LEARNING_OBJECT_KEY, "forAdmin"] }); + }, + }); +} + +export function useDeleteLearningObjectMutation(): UseMutationReturnType< + LearningObject, + AxiosError, + { hruid: string; language: Language; version: number }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ hruid, language, version }) => + await learningObjectController.deleteLearningObject(hruid, language, version), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [LEARNING_OBJECT_KEY, "forAdmin"] }); + }, + }); +} diff --git a/frontend/src/queries/learning-paths.ts b/frontend/src/queries/learning-paths.ts index 6cccc37c..80d59afa 100644 --- a/frontend/src/queries/learning-paths.ts +++ b/frontend/src/queries/learning-paths.ts @@ -1,8 +1,16 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; -import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; import { getLearningPathController } from "@/controllers/controllers"; -import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { AxiosError } from "axios"; +import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; +import type { LearningPath } from "@/data-objects/learning-paths/learning-path"; export const LEARNING_PATH_KEY = "learningPath"; const learningPathController = getLearningPathController(); @@ -33,6 +41,58 @@ export function useGetAllLearningPathsByThemeAndLanguageQuery( }); } +export function useGetAllLearningPathsByAdminQuery( + admin: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_PATH_KEY, "getAllByAdmin", admin], + queryFn: async () => learningPathController.getAllByAdminRaw(toValue(admin)!), + enabled: () => Boolean(toValue(admin)), + }); +} + +export function usePostLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { learningPath: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningPath }) => learningPathController.postLearningPath(learningPath), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + +export function usePutLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { learningPath: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningPath }) => learningPathController.putLearningPath(learningPath), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + +export function useDeleteLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { hruid: string; language: Language }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ hruid, language }) => learningPathController.deleteLearningPath(hruid, language), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + export function useSearchLearningPathQuery( query: MaybeRefOrGetter, language: MaybeRefOrGetter, diff --git a/frontend/src/queries/students.ts b/frontend/src/queries/students.ts index da87d28b..1d6794f1 100644 --- a/frontend/src/queries/students.ts +++ b/frontend/src/queries/students.ts @@ -33,7 +33,7 @@ function studentsQueryKey(full: boolean): [string, boolean] { function studentQueryKey(username: string): [string, string] { return ["student", username]; } -function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] { +export function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] { return ["student-classes", username, full]; } function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 359eab1a..fa3d6e05 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,8 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue"; import SingleTheme from "@/views/SingleTheme.vue"; import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; import authService from "@/services/auth/auth-service"; +import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue"; +import { allowRedirect, Redirect } from "@/utils/redirect.ts"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -105,6 +107,12 @@ const router = createRouter({ component: SingleDiscussion, meta: { requiresAuth: true }, }, + { + path: "/my-content", + name: "OwnLearningContentPage", + component: OwnLearningContentPage, + meta: { requiresAuth: true }, + }, { path: "/learningPath", children: [ @@ -143,7 +151,11 @@ router.beforeEach(async (to, _from, next) => { // Verify if user is logged in before accessing certain routes if (to.meta.requiresAuth) { if (!authService.isLoggedIn.value && !(await authService.loadUser())) { - next("/login"); + const path = to.fullPath; + if (allowRedirect(path)) { + localStorage.setItem(Redirect.AFTER_LOGIN_KEY, path); + } + next(Redirect.LOGIN); } else { next(); } diff --git a/frontend/src/utils/redirect.ts b/frontend/src/utils/redirect.ts new file mode 100644 index 00000000..f3ec0e75 --- /dev/null +++ b/frontend/src/utils/redirect.ts @@ -0,0 +1,12 @@ +export enum Redirect { + AFTER_LOGIN_KEY = "redirectAfterLogin", + HOME = "/user", + LOGIN = "/login", + ROOT = "/", +} + +const NOT_ALLOWED_REDIRECTS = new Set([Redirect.HOME, Redirect.ROOT, Redirect.LOGIN]); + +export function allowRedirect(path: string): boolean { + return !NOT_ALLOWED_REDIRECTS.has(path as Redirect); +} diff --git a/frontend/src/views/CallbackPage.vue b/frontend/src/views/CallbackPage.vue index cd004eae..d4d300e4 100644 --- a/frontend/src/views/CallbackPage.vue +++ b/frontend/src/views/CallbackPage.vue @@ -3,6 +3,7 @@ import { useI18n } from "vue-i18n"; import { onMounted, ref, type Ref } from "vue"; import auth from "../services/auth/auth-service.ts"; + import { Redirect } from "@/utils/redirect.ts"; const { t } = useI18n(); @@ -10,10 +11,20 @@ const errorMessage: Ref = ref(null); + async function redirectPage(): Promise { + const redirectUrl = localStorage.getItem(Redirect.AFTER_LOGIN_KEY); + if (redirectUrl) { + localStorage.removeItem(Redirect.AFTER_LOGIN_KEY); + await router.replace(redirectUrl); + } else { + await router.replace(Redirect.HOME); + } + } + onMounted(async () => { try { await auth.handleLoginCallback(); - await router.replace("/user"); // Redirect to theme page + await redirectPage(); } catch (error) { errorMessage.value = `${t("loginUnexpectedError")}: ${error}`; } diff --git a/frontend/src/views/LoginPage.vue b/frontend/src/views/LoginPage.vue index 5d5a40a8..29b1abf8 100644 --- a/frontend/src/views/LoginPage.vue +++ b/frontend/src/views/LoginPage.vue @@ -3,6 +3,7 @@ import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; import auth from "@/services/auth/auth-service.ts"; import { watch } from "vue"; + import { AccountType } from "@dwengo-1/common/util/account-types"; const router = useRouter(); @@ -17,11 +18,11 @@ ); async function loginAsStudent(): Promise { - await auth.loginAs("student"); + await auth.loginAs(AccountType.Student); } async function loginAsTeacher(): Promise { - await auth.loginAs("teacher"); + await auth.loginAs(AccountType.Teacher); } diff --git a/frontend/src/views/SingleTheme.vue b/frontend/src/views/SingleTheme.vue index c858ac26..1cd9afab 100644 --- a/frontend/src/views/SingleTheme.vue +++ b/frontend/src/views/SingleTheme.vue @@ -35,13 +35,14 @@ @@ -469,9 +484,24 @@ + > + +
- +
diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index dc444156..a6080855 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -22,6 +22,7 @@ import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; import QuestionNotification from "@/components/QuestionNotification.vue"; + import { AccountType } from "@dwengo-1/common/util/account-types"; const router = useRouter(); const route = useRoute(); @@ -235,8 +236,10 @@

- - + -
+