Tibo De Peuter 2025-05-16 10:57:17 +02:00
commit f05994fa5e
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
70 changed files with 904 additions and 357 deletions

View file

@ -8,6 +8,7 @@
### Dwengo ### ### Dwengo ###
DWENGO_PORT=3000 DWENGO_PORT=3000
DWENGO_RUN_MODE=test
DWENGO_DB_NAME=":memory:" DWENGO_DB_NAME=":memory:"
DWENGO_DB_UPDATE=true DWENGO_DB_UPDATE=true

View file

@ -1,10 +1,11 @@
import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; import { UnauthorizedException } from '../exceptions/unauthorized-exception.js';
import { getLogger } from '../logging/initalize.js'; import { getLogger } from '../logging/initalize.js';
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.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 { 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 { interface FrontendIdpConfig {
authority: string; 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<void> { export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> {
const auth = req.auth; const auth = req.auth;
if (!auth) { if (!auth) {
@ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response)
firstName: auth.firstName ?? '', firstName: auth.firstName ?? '',
lastName: auth.lastName ?? '', lastName: auth.lastName ?? '',
}; };
if (auth.accountType === 'student') { if (auth.accountType === AccountType.Student) {
await createOrUpdateStudent(userData); await createOrUpdateStudent(userData);
logger.debug(`Synchronized student ${userData.username} with IDP`); logger.debug(`Synchronized student ${userData.username} with IDP`);
} else { } else {

View file

@ -73,7 +73,7 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise<void> { function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise<void> {
return async (req, res) => { return async (req, res) => {
const path: LearningPath = req.body; const path = req.body as LearningPath;
const { hruid: hruidParam, language: languageParam } = req.params; const { hruid: hruidParam, language: languageParam } = req.params;
if (isPut) { if (isPut) {

View file

@ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response):
const classId = req.body.classId; const classId = req.body.classId;
requireFields({ username, classId }); requireFields({ username, classId });
const request = await createClassJoinRequest(username, classId); const request = await createClassJoinRequest(username, classId.toUpperCase());
res.json({ request }); res.json({ request });
} }

View file

@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { requireFields } from './error-helper.js'; import { requireFields } from './error-helper.js';
import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js';
import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; 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<void> { export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username; const username = req.params.username;
@ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom
const classId = req.body.class; const classId = req.body.class;
requireFields({ sender, receiver, classId }); requireFields({ sender, receiver, classId });
if (sender === receiver) {
throw new ConflictException('Cannot send an invitation to yourself');
}
const data = req.body as TeacherInvitationData; const data = req.body as TeacherInvitationData;
const invitation = await createInvitation(data); const invitation = await createInvitation(data);

View file

@ -3,9 +3,9 @@ import { Question } from '../../entities/questions/question.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { Student } from '../../entities/users/student.entity.js'; import { Student } from '../../entities/users/student.entity.js';
import { LearningObject } from '../../entities/content/learning-object.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 { Assignment } from '../../entities/assignments/assignment.entity.js';
import { Loaded } from '@mikro-orm/core'; import { Loaded } from '@mikro-orm/core';
import { Group } from '../../entities/assignments/group.entity';
export class QuestionRepository extends DwengoEntityRepository<Question> { export class QuestionRepository extends DwengoEntityRepository<Question> {
public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> { public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> {

View file

@ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO {
}; };
} }
export function mapToUsername(user: { username: string }): string {
return user.username;
}
export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T { export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T {
userInstance.username = userData.username; userInstance.username = userData.username;
userInstance.firstName = userData.firstName; userInstance.firstName = userData.firstName;

View file

@ -7,8 +7,6 @@ import * as express from 'express';
import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticatedRequest } from './authenticated-request.js';
import { AuthenticationInfo } from './authentication-info.js'; import { AuthenticationInfo } from './authentication-info.js';
import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js';
import { ForbiddenException } from '../../exceptions/forbidden-exception.js';
import { RequestHandler } from 'express';
const JWKS_CACHE = true; const JWKS_CACHE = true;
const JWKS_RATE_LIMIT = true; const JWKS_RATE_LIMIT = true;
@ -109,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response
} }
export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; 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, req: AuthenticatedRequest) => boolean | Promise<boolean>): RequestHandler {
return async (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): Promise<void> => {
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 === 'student');
/**
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
*/
export const teachersOnly = authorize((auth) => auth.accountType === 'teacher');

View file

@ -1,8 +1,15 @@
import { Request } from 'express'; import { Request } from 'express';
import { JwtPayload } from 'jsonwebtoken'; import { JwtPayload } from 'jsonwebtoken';
import { AuthenticationInfo } from './authentication-info.js'; 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<string, unknown> = Record<string, unknown>,
> extends Request<P, ResBody, ReqBody, ReqQuery, Locals> {
// Properties are optional since the user is not necessarily authenticated. // Properties are optional since the user is not necessarily authenticated.
jwtPayload?: JwtPayload; jwtPayload?: JwtPayload;
auth?: AuthenticationInfo; auth?: AuthenticationInfo;

View file

@ -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));
});

View file

@ -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<P, ResBody, ReqBody, ReqQuery, Locals extends Record<string, unknown>>(
accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>) => boolean | Promise<boolean>
): RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals> {
// Bypass authentication during testing
if (getEnvVar(envVars.RunMode) === 'test') {
return async (
_req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
_res: express.Response,
next: express.NextFunction
): Promise<void> => {
next();
};
}
return async (
req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
_res: express.Response,
next: express.NextFunction
): Promise<void> => {
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);

View file

@ -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<boolean> {
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);
});

View file

@ -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);
});

View file

@ -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;
});

View file

@ -1,8 +1,8 @@
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import learningObjectService from '../../../services/learning-objects/learning-object-service.js'; import learningObjectService from '../../../services/learning-objects/learning-object-service.js';
import { authorize } from '../auth.js';
import { AuthenticatedRequest } from '../authenticated-request.js'; import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js'; import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
export const onlyAdminsForLearningObject = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { export const onlyAdminsForLearningObject = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { hruid } = req.params; const { hruid } = req.params;

View file

@ -1,8 +1,8 @@
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import learningPathService from '../../../services/learning-paths/learning-path-service.js'; import learningPathService from '../../../services/learning-paths/learning-path-service.js';
import { authorize } from '../auth.js';
import { AuthenticatedRequest } from '../authenticated-request.js'; import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js'; import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const adminsForLearningPath = await learningPathService.getAdmins({ const adminsForLearningPath = await learningPathService.getAdmins({

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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
);

View file

@ -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);

View file

@ -1,16 +1,18 @@
import express from 'express'; import express from 'express';
import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; 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 }); 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; export default router;

View file

@ -9,22 +9,25 @@ import {
putAssignmentHandler, putAssignmentHandler,
} from '../controllers/assignments.js'; } from '../controllers/assignments.js';
import groupRouter from './groups.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 }); 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); router.use('/:assignmentid/groups', groupRouter);

View file

@ -1,28 +1,35 @@
import express from 'express'; import express from 'express';
import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js';
import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
// Returns auth configuration for frontend // Returns auth configuration for frontend
router.get('/config', (_req, res) => { router.get('/config', handleGetFrontendAuthConfig);
res.json(getFrontendAuthConfig());
});
router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { 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!' }); res.json({ message: 'If you see this, you should be authenticated!' });
}); });
router.get('/testStudentsOnly', studentsOnly, (_req, res) => { 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!' }); res.json({ message: 'If you see this, you should be a student!' });
}); });
router.get('/testTeachersOnly', teachersOnly, (_req, res) => { 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!' }); 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; export default router;

View file

@ -14,33 +14,35 @@ import {
putClassHandler, putClassHandler,
} from '../controllers/classes.js'; } from '../controllers/classes.js';
import assignmentRouter from './assignments.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(); const router = express.Router();
// Root endpoint used to search objects router.get('/', adminOnly, getAllClassesHandler);
router.get('/', 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); router.use('/:classid/assignments', assignmentRouter);

View file

@ -8,22 +8,24 @@ import {
getGroupSubmissionsHandler, getGroupSubmissionsHandler,
putGroupHandler, putGroupHandler,
} from '../controllers/groups.js'; } 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 }); const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects router.get('/', onlyAllowIfHasAccessToAssignment, getAllGroupsHandler);
router.get('/', 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; export default router;

View file

@ -7,12 +7,11 @@ import {
handleDeleteLearningObject, handleDeleteLearningObject,
handlePostLearningObject, handlePostLearningObject,
} from '../controllers/learning-objects.js'; } from '../controllers/learning-objects.js';
import submissionRoutes from './submissions.js'; import submissionRoutes from './submissions.js';
import questionRoutes from './questions.js'; import questionRoutes from './questions.js';
import fileUpload from 'express-fileupload'; import fileUpload from 'express-fileupload';
import { teachersOnly } from '../middleware/auth/auth.js';
import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js'; 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(); const router = express.Router();
@ -26,7 +25,7 @@ const router = express.Router();
// Route 2: list of object data // Route 2: list of object data
// Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie // 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); router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLearningObject);
@ -34,7 +33,7 @@ router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLea
// Query: language // Query: language
// Route to fetch data of one learning object based on its hruid // Route to fetch data of one learning object based on its hruid
// Example: http://localhost:3000/learningObject/un_ai7 // Example: http://localhost:3000/learningObject/un_ai7
router.get('/:hruid', getLearningObject); router.get('/:hruid', authenticatedOnly, getLearningObject);
// Parameter: hruid of learning object // Parameter: hruid of learning object
// Query: language // Query: language
@ -50,12 +49,12 @@ router.use('/:hruid/:version/questions', questionRoutes);
// Query: language, version (optional) // Query: language, version (optional)
// Route to fetch the HTML rendering of one learning object based on its hruid. // Route to fetch the HTML rendering of one learning object based on its hruid.
// Example: http://localhost:3000/learningObject/un_ai7/html // 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. // Parameter: hruid of learning object, name of attachment.
// Query: language, version (optional). // Query: language, version (optional).
// Route to get the raw data of the attachment for one learning object based on its hruid. // 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 // 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; export default router;

View file

@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js'; import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js';
import { teachersOnly } from '../middleware/auth/auth.js';
import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js'; import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js';
const router = express.Router(); const router = express.Router();
@ -24,7 +24,7 @@ const router = express.Router();
// Route to fetch learning paths based on a theme // Route to fetch learning paths based on a theme
// Example: http://localhost:3000/learningPath?theme=kiks // Example: http://localhost:3000/learningPath?theme=kiks
router.get('/', getLearningPaths); router.get('/', authenticatedOnly, getLearningPaths);
router.post('/', teachersOnly, postLearningPath); router.post('/', teachersOnly, postLearningPath);
router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath); router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath);

View file

@ -1,20 +1,25 @@
import express from 'express'; import express from 'express';
import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js';
import answerRoutes from './answers.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 }); const router = express.Router({ mergeParams: true });
// Query language // Query language
// Root endpoint used to search objects // Root endpoint used to search objects
router.get('/', getAllQuestionsHandler); router.get('/', authenticatedOnly, getAllQuestionsHandler);
router.post('/', createQuestionHandler); router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler);
router.delete('/:seq', deleteQuestionHandler);
// Information about a question with id // 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); router.use('/:seq/answers', answerRoutes);

View file

@ -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('/auth', authRouter /* #swagger.tags = ['Auth'] */);
router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); router.use(
router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); '/class',
router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); 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; export default router;

View file

@ -5,15 +5,19 @@ import {
getStudentRequestHandler, getStudentRequestHandler,
getStudentRequestsHandler, getStudentRequestsHandler,
} from '../controllers/students.js'; } 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 }); 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; export default router;

View file

@ -11,33 +11,37 @@ import {
getStudentSubmissionsHandler, getStudentSubmissionsHandler,
} from '../controllers/students.js'; } from '../controllers/students.js';
import joinRequestRouter from './student-join-requests.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(); const router = express.Router();
// Root endpoint used to search objects // 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 // Information about a student's profile
router.get('/:username', getStudentHandler); router.get('/:username', preventImpersonation, getStudentHandler);
// The list of classes a student is in // 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 // 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 // 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 // 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 // A list of questions a user has created
router.get('/:username/questions', getStudentQuestionsHandler); router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler);
router.use('/:username/joinRequests', joinRequestRouter); router.use('/:username/joinRequests', joinRequestRouter);

View file

@ -1,15 +1,15 @@
import express from 'express'; import express from 'express';
import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; 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 }); const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects router.get('/', adminOnly, getSubmissionsHandler);
router.get('/', getSubmissionsHandler);
router.post('/', createSubmissionHandler); router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler);
// Information about an submission with id 'id' router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler);
router.get('/:id', getSubmissionHandler);
router.delete('/:id', deleteSubmissionHandler); router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler);
export default router; export default router;

View file

@ -6,17 +6,24 @@ import {
getInvitationHandler, getInvitationHandler,
updateInvitationHandler, updateInvitationHandler,
} from '../controllers/teacher-invitations.js'; } 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 }); 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; export default router;

View file

@ -10,25 +10,27 @@ import {
updateStudentJoinRequestHandler, updateStudentJoinRequestHandler,
} from '../controllers/teachers.js'; } from '../controllers/teachers.js';
import invitationRouter from './teacher-invitations.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(); const router = express.Router();
// Root endpoint used to search objects // 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 // Invitations to other classes a teacher received
router.use('/invitations', invitationRouter); router.use('/invitations', invitationRouter);

View file

@ -1,14 +1,15 @@
import express from 'express'; import express from 'express';
import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js';
import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
// Query: language // Query: language
// Route to fetch list of {key, title, description, image} themes in their respective 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) // Arg: theme (key)
// Route to fetch list of hruids based on theme // Route to fetch list of hruids based on theme
router.get('/:theme', getHruidsByThemeHandler); router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler);
export default router; export default router;

View file

@ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat
return mapToAnswerDTO(answer); return mapToAnswerDTO(answer);
} }
async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> { export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> {
const answerRepository = getAnswerRepository(); const answerRepository = getAnswerRepository();
const question = await fetchQuestion(questionId); const question = await fetchQuestion(questionId);
const answer = await answerRepository.findAnswer(question, sequenceNumber); const answer = await answerRepository.findAnswer(question, sequenceNumber);

View file

@ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou
return group; return group;
} }
export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise<Group[]> {
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<GroupDTO> { export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> {
const group = await fetchGroup(classId, assignmentNumber, groupNumber); const group = await fetchGroup(classId, assignmentNumber, groupNumber);
return mapToGroupDTO(group, group.assignment.within); return mapToGroupDTO(group, group.assignment.within);

View file

@ -13,6 +13,7 @@ import { fetchStudent } from './students.js';
import { NotFoundException } from '../exceptions/not-found-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js';
import { FALLBACK_VERSION_NUM } from '../config.js'; import { FALLBACK_VERSION_NUM } from '../config.js';
import { fetchAssignment } from './assignments.js'; import { fetchAssignment } from './assignments.js';
import { ConflictException } from '../exceptions/conflict-exception.js';
export async function getQuestionsAboutLearningObjectInAssignment( export async function getQuestionsAboutLearningObjectInAssignment(
loId: LearningObjectIdentifier, loId: LearningObjectIdentifier,
@ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat
const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); 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({ const question = await questionRepository.createQuestion({
loId, loId,
author, author,
inGroup: inGroup!, inGroup: inGroup,
content, content,
}); });

View file

@ -24,7 +24,8 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js'; 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<StudentDTO[] | string[]> { export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> {
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
@ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise<StudentDTO[] | stri
return users.map(mapToStudentDTO); return users.map(mapToStudentDTO);
} }
return users.map((user) => user.username); return users.map(mapToUsername);
} }
export async function fetchStudent(username: string): Promise<Student> { export async function fetchStudent(username: string): Promise<Student> {
@ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> {
const newStudent = mapToStudent(userData); const newStudent = mapToStudent(userData);
await studentRepository.save(newStudent, { preventOverwrite: true }); await studentRepository.save(newStudent, { preventOverwrite: true });
return userData; return mapToStudentDTO(newStudent);
} }
export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> { export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> {

View file

@ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise<Tea
throw new ConflictException('The teacher sending the invite is not part of the class'); throw new ConflictException('The teacher sending the invite is not part of the class');
} }
if (cls.teachers.contains(receiver)) {
throw new ConflictException('The teacher receiving the invite is already part of the class');
}
const newInvitation = mapToInvitation(sender, receiver, cls); const newInvitation = mapToInvitation(sender, receiver, cls);
await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true });

View file

@ -18,6 +18,7 @@ import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js'; import { ConflictException } from '../exceptions/conflict-exception.js';
import { mapToUsername } from '../interfaces/user.js';
export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> {
const teacherRepository: TeacherRepository = getTeacherRepository(); const teacherRepository: TeacherRepository = getTeacherRepository();
@ -26,7 +27,7 @@ export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | stri
if (full) { if (full) {
return users.map(mapToTeacherDTO); return users.map(mapToTeacherDTO);
} }
return users.map((user) => user.username); return users.map(mapToUsername);
} }
export async function fetchTeacher(username: string): Promise<Teacher> { export async function fetchTeacher(username: string): Promise<Teacher> {
@ -45,7 +46,8 @@ export async function getTeacher(username: string): Promise<TeacherDTO> {
return mapToTeacherDTO(user); return mapToTeacherDTO(user);
} }
export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> { // TODO update parameter
export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise<TeacherDTO> {
const teacherRepository: TeacherRepository = getTeacherRepository(); const teacherRepository: TeacherRepository = getTeacherRepository();
const newTeacher = mapToTeacher(userData); 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 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) { if (full) {
return students; return students;

View file

@ -15,7 +15,6 @@ import {
import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; import { BadRequestException } from '../../src/exceptions/bad-request-exception.js';
import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js';
import { getStudentRequestsHandler } from '../../src/controllers/students.js'; import { getStudentRequestsHandler } from '../../src/controllers/students.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { getClassHandler } from '../../src/controllers/classes'; import { getClassHandler } from '../../src/controllers/classes';
import { getClass02 } from '../test_assets/classes/classes.testdata'; import { getClass02 } from '../test_assets/classes/classes.testdata';
@ -97,7 +96,7 @@ describe('Teacher controllers', () => {
}); });
it('Teacher list', async () => { it('Teacher list', async () => {
req = { query: { full: 'true' } }; req = { query: { full: 'false' } };
await getAllTeachersHandler(req as Request, res as Response); await getAllTeachersHandler(req as Request, res as Response);
@ -105,8 +104,7 @@ describe('Teacher controllers', () => {
const result = jsonMock.mock.lastCall?.[0]; const result = jsonMock.mock.lastCall?.[0];
const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); expect(result.teachers).toContain('testleerkracht1');
expect(teacherUsernames).toContain('testleerkracht1');
expect(result.teachers).toHaveLength(5); expect(result.teachers).toHaveLength(5);
}); });

View file

@ -13,8 +13,8 @@ export interface QuestionDTO {
export interface QuestionData { export interface QuestionData {
author?: string; author?: string;
content: string;
inGroup: GroupDTO; inGroup: GroupDTO;
content: string;
} }
export interface QuestionId { export interface QuestionId {

View file

@ -0,0 +1,4 @@
export enum AccountType {
Student = 'student',
Teacher = 'teacher',
}

View file

@ -26,7 +26,59 @@ const doc = {
], ],
components: { components: {
securitySchemes: { 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', type: 'oauth2',
flows: { flows: {
implicit: { implicit: {
@ -39,7 +91,7 @@ const doc = {
}, },
}, },
}, },
teacher: { teacherProduction: {
type: 'oauth2', type: 'oauth2',
flows: { flows: {
implicit: { implicit: {

View file

@ -17,6 +17,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@dwengo-1/common": "^0.2.0",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.69.0",
"@tanstack/vue-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",

View file

@ -31,4 +31,9 @@
></v-text-field> ></v-text-field>
</template> </template>
<style scoped></style> <style scoped>
.search-field {
width: 25%;
min-width: 300px;
}
</style>

View file

@ -53,9 +53,9 @@
white-space: normal; white-space: normal;
} }
.results-grid { .results-grid {
margin: 20px; margin: 20px auto;
display: flex; display: flex;
align-items: stretch; justify-content: center;
gap: 20px; gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
} }

View file

@ -16,6 +16,7 @@
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
const name: string = auth.authState.user!.profile.name!; const name: string = auth.authState.user!.profile.name!;
const username = auth.authState.user!.profile.preferred_username!;
const email = auth.authState.user!.profile.email; const email = auth.authState.user!.profile.email;
const initials: string = name const initials: string = name
.split(" ") .split(" ")
@ -183,10 +184,15 @@
<v-card> <v-card>
<v-card-text> <v-card-text>
<div class="mx-auto text-center"> <div class="mx-auto text-center">
<v-avatar color="#0e6942"> <v-avatar
<span class="text-h5">{{ initials }}</span> color="#0e6942"
size="large"
class="user-button mb-3"
>
<span>{{ initials }}</span>
</v-avatar> </v-avatar>
<h3>{{ name }}</h3> <h3>{{ name }}</h3>
<p class="text-caption mt-1">{{ username }}</p>
<p class="text-caption mt-1">{{ email }}</p> <p class="text-caption mt-1">{{ email }}</p>
<v-divider class="my-3"></v-divider> <v-divider class="my-3"></v-divider>
<v-btn <v-btn

View file

@ -6,6 +6,7 @@
import type { AnswersResponse } from "@/controllers/answers"; import type { AnswersResponse } from "@/controllers/answers";
import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer"; import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer";
import authService from "@/services/auth/auth-service"; import authService from "@/services/auth/auth-service";
import { AccountType } from "@dwengo-1/common/util/account-types";
const props = defineProps<{ const props = defineProps<{
question: QuestionDTO; question: QuestionDTO;
@ -80,7 +81,7 @@
{{ question.content }} {{ question.content }}
</div> </div>
<div <div
v-if="authService.authState.activeRole === 'teacher'" v-if="authService.authState.activeRole === AccountType.Teacher"
class="answer-input-container" class="answer-input-container"
> >
<input <input

View file

@ -15,6 +15,7 @@ import { invalidateAllGroupKeys } from "./groups";
import { invalidateAllSubmissionKeys } from "./submissions"; import { invalidateAllSubmissionKeys } from "./submissions";
import type { TeachersResponse } from "@/controllers/teachers"; import type { TeachersResponse } from "@/controllers/teachers";
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations";
import { studentClassesQueryKey } from "@/queries/students.ts";
const classController = new ClassController(); const classController = new ClassController();
@ -171,6 +172,8 @@ export function useClassDeleteStudentMutation(): UseMutationReturnType<
await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); 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, true) });
await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); 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) });
}, },
}); });
} }

View file

@ -33,7 +33,7 @@ function studentsQueryKey(full: boolean): [string, boolean] {
function studentQueryKey(username: string): [string, string] { function studentQueryKey(username: string): [string, string] {
return ["student", username]; 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]; return ["student-classes", username, full];
} }
function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] { function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] {

View file

@ -1,20 +1,21 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from 'vue-router';
import SingleAssignment from "@/views/assignments/SingleAssignment.vue"; import SingleAssignment from '@/views/assignments/SingleAssignment.vue';
import SingleClass from "@/views/classes/SingleClass.vue"; import SingleClass from '@/views/classes/SingleClass.vue';
import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue"; import SingleDiscussion from '@/views/discussions/SingleDiscussion.vue';
import NotFound from "@/components/errors/NotFound.vue"; import NotFound from '@/components/errors/NotFound.vue';
import CreateAssignment from "@/views/assignments/CreateAssignment.vue"; import CreateAssignment from '@/views/assignments/CreateAssignment.vue';
import CreateDiscussion from "@/views/discussions/CreateDiscussion.vue"; import CreateDiscussion from '@/views/discussions/CreateDiscussion.vue';
import CallbackPage from "@/views/CallbackPage.vue"; import CallbackPage from '@/views/CallbackPage.vue';
import UserClasses from "@/views/classes/UserClasses.vue"; import UserClasses from '@/views/classes/UserClasses.vue';
import UserAssignments from "@/views/assignments/UserAssignments.vue"; import UserAssignments from '@/views/assignments/UserAssignments.vue';
import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; import LearningPathPage from '@/views/learning-paths/LearningPathPage.vue';
import LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue"; import LearningPathSearchPage from '@/views/learning-paths/LearningPathSearchPage.vue';
import UserHomePage from "@/views/homepage/UserHomePage.vue"; import UserHomePage from '@/views/homepage/UserHomePage.vue';
import SingleTheme from "@/views/SingleTheme.vue"; import SingleTheme from '@/views/SingleTheme.vue';
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; import LearningObjectView from '@/views/learning-paths/learning-object/LearningObjectView.vue';
import authService from "@/services/auth/auth-service"; import authService from '@/services/auth/auth-service';
import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue"; import OwnLearningContentPage from '@/views/own-learning-content/OwnLearningContentPage.vue';
import { allowRedirect, Redirect } from '@/utils/redirect.ts';
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -150,7 +151,11 @@ router.beforeEach(async (to, _from, next) => {
// Verify if user is logged in before accessing certain routes // Verify if user is logged in before accessing certain routes
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
if (!authService.isLoggedIn.value && !(await authService.loadUser())) { 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 { } else {
next(); next();
} }

View file

@ -0,0 +1,12 @@
export enum Redirect {
AFTER_LOGIN_KEY = "redirectAfterLogin",
HOME = "/user",
LOGIN = "/login",
ROOT = "/",
}
const NOT_ALLOWED_REDIRECTS = new Set<Redirect>([Redirect.HOME, Redirect.ROOT, Redirect.LOGIN]);
export function allowRedirect(path: string): boolean {
return !NOT_ALLOWED_REDIRECTS.has(path as Redirect);
}

View file

@ -3,6 +3,7 @@
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { onMounted, ref, type Ref } from "vue"; import { onMounted, ref, type Ref } from "vue";
import auth from "../services/auth/auth-service.ts"; import auth from "../services/auth/auth-service.ts";
import { Redirect } from "@/utils/redirect.ts";
const { t } = useI18n(); const { t } = useI18n();
@ -10,10 +11,20 @@
const errorMessage: Ref<string | null> = ref(null); const errorMessage: Ref<string | null> = ref(null);
async function redirectPage(): Promise<void> {
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 () => { onMounted(async () => {
try { try {
await auth.handleLoginCallback(); await auth.handleLoginCallback();
await router.replace("/user"); // Redirect to theme page await redirectPage();
} catch (error) { } catch (error) {
errorMessage.value = `${t("loginUnexpectedError")}: ${error}`; errorMessage.value = `${t("loginUnexpectedError")}: ${error}`;
} }

View file

@ -3,6 +3,7 @@
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
import auth from "@/services/auth/auth-service.ts"; import auth from "@/services/auth/auth-service.ts";
import { watch } from "vue"; import { watch } from "vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const router = useRouter(); const router = useRouter();
@ -17,11 +18,11 @@
); );
async function loginAsStudent(): Promise<void> { async function loginAsStudent(): Promise<void> {
await auth.loginAs("student"); await auth.loginAs(AccountType.Student);
} }
async function loginAsTeacher(): Promise<void> { async function loginAsTeacher(): Promise<void> {
await auth.loginAs("teacher"); await auth.loginAs(AccountType.Teacher);
} }
</script> </script>

View file

@ -35,13 +35,14 @@
</script> </script>
<template> <template>
<div class="container"> <div class="container d-flex flex-column align-items-center justify-center">
<using-query-result :query-result="themeQueryResult"> <using-query-result :query-result="themeQueryResult">
<h1>{{ currentThemeInfo!!.title }}</h1> <h1>{{ currentThemeInfo!!.title }}</h1>
<p>{{ currentThemeInfo!!.description }}</p> <p>{{ currentThemeInfo!!.description }}</p>
<div class="search-field-container"> <br />
<div class="search-field-container mt-sm-6">
<v-text-field <v-text-field
class="search-field" class="search-field mx-auto"
:label="t('search')" :label="t('search')"
append-inner-icon="mdi-magnify" append-inner-icon="mdi-magnify"
v-model="searchFilter" v-model="searchFilter"
@ -60,13 +61,15 @@
<style scoped> <style scoped>
.search-field-container { .search-field-container {
display: block; justify-content: center !important;
margin: 20px;
} }
.search-field { .search-field {
max-width: 300px; width: 25%;
min-width: 300px;
} }
.container { .container {
padding: 20px; padding: 20px;
justify-content: center;
justify-items: center;
} }
</style> </style>

View file

@ -14,6 +14,7 @@
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import { useCreateAssignmentMutation } from "@/queries/assignments.ts"; import { useCreateAssignmentMutation } from "@/queries/assignments.ts";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { AccountType } from "@dwengo-1/common/util/account-types";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -23,7 +24,7 @@
onMounted(async () => { onMounted(async () => {
// Redirect student // Redirect student
if (role.value === "student") { if (role.value === AccountType.Student) {
await router.push("/user"); await router.push("/user");
} }

View file

@ -8,9 +8,10 @@
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { AccountType } from "@dwengo-1/common/util/account-types";
const role = auth.authState.activeRole; const role = auth.authState.activeRole;
const isTeacher = computed(() => role === "teacher"); const isTeacher = computed(() => role === AccountType.Teacher);
const route = useRoute(); const route = useRoute();
const classId = ref<string>(route.params.classId as string); const classId = ref<string>(route.params.classId as string);

View file

@ -9,6 +9,7 @@
import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { asyncComputed } from "@vueuse/core"; import { asyncComputed } from "@vueuse/core";
import { useDeleteAssignmentMutation } from "@/queries/assignments.ts"; import { useDeleteAssignmentMutation } from "@/queries/assignments.ts";
import { AccountType } from "@dwengo-1/common/util/account-types";
import "../../assets/common.css"; import "../../assets/common.css";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
@ -17,7 +18,7 @@
const role = ref(auth.authState.activeRole); const role = ref(auth.authState.activeRole);
const username = ref<string>(""); const username = ref<string>("");
const isTeacher = computed(() => role.value === "teacher"); const isTeacher = computed(() => role.value === AccountType.Teacher);
// Fetch and store all the teacher's classes // Fetch and store all the teacher's classes
let classesQueryResults = undefined; let classesQueryResults = undefined;

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import { useClassQuery } from "@/queries/classes";
import { defineProps } from "vue";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
const props = defineProps({
classId: String,
});
const classQuery = useClassQuery(props.classId);
</script>
<template>
<using-query-result
:query-result="classQuery"
v-slot="{ data: classResponse }"
>
<span>{{ classResponse?.class.displayName }}</span>
</using-query-result>
</template>

View file

@ -77,7 +77,7 @@
}, },
onError: (e) => { onError: (e) => {
dialog.value = false; dialog.value = false;
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}, },
); );
@ -105,7 +105,7 @@
} }
}, },
onError: (e) => { onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}, },
); );
@ -126,7 +126,7 @@
usernameTeacher.value = ""; usernameTeacher.value = "";
}, },
onError: (e) => { onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}); });
} }

View file

@ -2,7 +2,7 @@
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import authState from "@/services/auth/auth-service.ts"; import authState from "@/services/auth/auth-service.ts";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { validate, version } from "uuid"; import { useRoute } from "vue-router";
import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students"; import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students";
import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
@ -15,6 +15,7 @@
import "../../assets/common.css"; import "../../assets/common.css";
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute();
// Username of logged in student // Username of logged in student
const username = ref<string | undefined>(undefined); const username = ref<string | undefined>(undefined);
@ -38,6 +39,11 @@
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
const queryCode = route.query.code as string | undefined;
if (queryCode) {
code.value = queryCode;
}
}); });
// Fetch all classes of the logged in student // Fetch all classes of the logged in student
@ -75,11 +81,15 @@
// The code a student sends in to join a class needs to be formatted as v4 to be valid // The code a student sends in to join a class needs to be formatted as v4 to be valid
// These rules are used to display a message to the user if they use a code that has an invalid format // These rules are used to display a message to the user if they use a code that has an invalid format
function codeRegex(value: string): boolean {
return /^[a-zA-Z0-9]{6}$/.test(value);
}
const codeRules = [ const codeRules = [
(value: string | undefined): string | boolean => { (value: string | undefined): string | boolean => {
if (value === undefined || value === "") { if (value === undefined || value === "") {
return true; return true;
} else if (value !== undefined && validate(value) && version(value) === 4) { } else if (codeRegex(value)) {
return true; return true;
} }
return t("invalidFormat"); return t("invalidFormat");
@ -92,7 +102,7 @@
// Function called when a student submits a code to join a class // Function called when a student submits a code to join a class
function submitCode(): void { function submitCode(): void {
// Check if the code is valid // Check if the code is valid
if (code.value !== undefined && validate(code.value) && version(code.value) === 4) { if (code.value !== undefined && codeRegex(code.value)) {
mutate( mutate(
{ username: username.value!, classId: code.value }, { username: username.value!, classId: code.value },
{ {
@ -100,7 +110,7 @@
showSnackbar(t("sent"), "success"); showSnackbar(t("sent"), "success");
}, },
onError: (e) => { onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}, },
); );
@ -260,7 +270,7 @@
<v-text-field <v-text-field
label="CODE" label="CODE"
v-model="code" v-model="code"
placeholder="XXXXXXXX-XXXX-4XXX-XXXX-XXXXXXXXXXXX" placeholder="XXXXXX"
:rules="codeRules" :rules="codeRules"
variant="outlined" variant="outlined"
></v-text-field> ></v-text-field>

View file

@ -8,7 +8,7 @@
import { useTeacherClassesQuery } from "@/queries/teachers"; import { useTeacherClassesQuery } from "@/queries/teachers";
import type { ClassesResponse } from "@/controllers/classes"; import type { ClassesResponse } from "@/controllers/classes";
import UsingQueryResult from "@/components/UsingQueryResult.vue"; import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useClassesQuery, useCreateClassMutation } from "@/queries/classes"; import { useCreateClassMutation } from "@/queries/classes";
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations";
import { import {
useRespondTeacherInvitationMutation, useRespondTeacherInvitationMutation,
@ -16,6 +16,7 @@
} from "@/queries/teacher-invitations"; } from "@/queries/teacher-invitations";
import { useDisplay } from "vuetify"; import { useDisplay } from "vuetify";
import "../../assets/common.css"; import "../../assets/common.css";
import ClassDisplay from "@/views/classes/ClassDisplay.vue";
const { t } = useI18n(); const { t } = useI18n();
@ -41,7 +42,6 @@
// Fetch all classes of the logged in teacher // Fetch all classes of the logged in teacher
const classesQuery = useTeacherClassesQuery(username, true); const classesQuery = useTeacherClassesQuery(username, true);
const allClassesQuery = useClassesQuery();
const { mutate } = useCreateClassMutation(); const { mutate } = useCreateClassMutation();
const getInvitationsQuery = useTeacherInvitationsReceivedQuery(username); const getInvitationsQuery = useTeacherInvitationsReceivedQuery(username);
const { mutate: respondToInvitation } = useRespondTeacherInvitationMutation(); const { mutate: respondToInvitation } = useRespondTeacherInvitationMutation();
@ -70,7 +70,7 @@
await getInvitationsQuery.refetch(); await getInvitationsQuery.refetch();
}, },
onError: (e) => { onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}); });
} }
@ -132,17 +132,12 @@
// Show the teacher, copying of the code was a successs // Show the teacher, copying of the code was a successs
const copied = ref(false); const copied = ref(false);
// Copy the generated code to the clipboard async function copyToClipboard(code: string, isDialog = false, isLink = false): Promise<void> {
async function copyToClipboard(): Promise<void> { const content = isLink ? `${window.location.origin}/user/class?code=${code}` : code;
await navigator.clipboard.writeText(code.value); await navigator.clipboard.writeText(content);
copied.value = true; copied.value = isDialog;
}
async function copyCode(selectedCode: string): Promise<void> { if (!isDialog) showSnackbar(t("copied"), "white");
code.value = selectedCode;
await copyToClipboard();
showSnackbar(t("copied"), "white");
copied.value = false;
} }
// Custom breakpoints // Custom breakpoints
@ -170,6 +165,7 @@
// Code display dialog logic // Code display dialog logic
const viewCodeDialog = ref(false); const viewCodeDialog = ref(false);
const selectedCode = ref(""); const selectedCode = ref("");
function openCodeDialog(codeToView: string): void { function openCodeDialog(codeToView: string): void {
selectedCode.value = codeToView; selectedCode.value = codeToView;
viewCodeDialog.value = true; viewCodeDialog.value = true;
@ -231,24 +227,38 @@
variant="text" variant="text"
> >
{{ c.displayName }} {{ c.displayName }}
<v-icon end> mdi-menu-right </v-icon> <v-icon end> mdi-menu-right</v-icon>
</v-btn> </v-btn>
</td> </td>
<td> <td>
<v-btn <v-row
v-if="!isMdAndDown" v-if="!isMdAndDown"
variant="text" dense
append-icon="mdi-content-copy" align="center"
@click="copyCode(c.id)" no-gutters
> >
{{ c.id }} <v-btn
</v-btn> variant="text"
append-icon="mdi-content-copy"
@click="copyToClipboard(c.id)"
>
{{ c.id }}
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(c.id, false, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</v-row>
<span <span
v-else v-else
style="cursor: pointer" style="cursor: pointer"
@click="openCodeDialog(c.id)" @click="openCodeDialog(c.id)"
><v-icon icon="mdi-eye"></v-icon >
></span> <v-icon icon="mdi-eye"></v-icon>
</span>
</td> </td>
<td>{{ c.students.length }}</td> <td>{{ c.students.length }}</td>
@ -300,8 +310,8 @@
type="submit" type="submit"
@click="createClass" @click="createClass"
block block
>{{ t("create") }}</v-btn >{{ t("create") }}
> </v-btn>
</v-form> </v-form>
</v-sheet> </v-sheet>
<v-container> <v-container>
@ -310,14 +320,29 @@
max-width="400px" max-width="400px"
> >
<v-card> <v-card>
<v-card-title class="headline">code</v-card-title> <v-card-title class="headline">{{ t("code") }}</v-card-title>
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="code" v-model="code"
readonly readonly
append-inner-icon="mdi-content-copy" >
@click:append-inner="copyToClipboard" <template #append>
></v-text-field> <v-btn
icon
variant="text"
@click="copyToClipboard(code, true)"
>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(code, true, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</template>
</v-text-field>
<v-slide-y-transition> <v-slide-y-transition>
<div <div
v-if="copied" v-if="copied"
@ -368,85 +393,75 @@
:query-result="getInvitationsQuery" :query-result="getInvitationsQuery"
v-slot="invitationsResponse: { data: TeacherInvitationsResponse }" v-slot="invitationsResponse: { data: TeacherInvitationsResponse }"
> >
<using-query-result <template v-if="invitationsResponse.data.invitations.length">
:query-result="allClassesQuery" <tr
v-slot="classesResponse: { data: ClassesResponse }" v-for="i in invitationsResponse.data.invitations as TeacherInvitationDTO[]"
> :key="i.classId"
<template v-if="invitationsResponse.data.invitations.length"> >
<tr <td>
v-for="i in invitationsResponse.data.invitations as TeacherInvitationDTO[]" <ClassDisplay :classId="i.classId" />
:key="i.classId" </td>
<td>
{{
(i.sender as TeacherDTO).firstName + " " + (i.sender as TeacherDTO).lastName
}}
</td>
<td class="text-right">
<span v-if="!isSmAndDown">
<div>
<v-btn
color="green"
@click="handleInvitation(i, true)"
class="mr-2"
>
{{ t("accept") }}
</v-btn>
<v-btn
color="red"
@click="handleInvitation(i, false)"
>
{{ t("deny") }}
</v-btn>
</div>
</span>
<span v-else>
<div>
<v-btn
@click="handleInvitation(i, true)"
class="mr-2"
icon="mdi-check-circle"
color="green"
variant="text"
>
</v-btn>
<v-btn
@click="handleInvitation(i, false)"
class="mr-2"
icon="mdi-close-circle"
color="red"
variant="text"
>
</v-btn>
</div>
</span>
</td>
</tr>
</template>
<template v-else>
<tr>
<td
colspan="3"
class="empty-message"
> >
<td> <v-icon
{{ icon="mdi-information-outline"
(classesResponse.data.classes as ClassDTO[]).filter( size="small"
(c) => c.id == i.classId,
)[0].displayName
}}
</td>
<td>
{{
(i.sender as TeacherDTO).firstName +
" " +
(i.sender as TeacherDTO).lastName
}}
</td>
<td class="text-right">
<span v-if="!isSmAndDown">
<div>
<v-btn
color="green"
@click="handleInvitation(i, true)"
class="mr-2"
>
{{ t("accept") }}
</v-btn>
<v-btn
color="red"
@click="handleInvitation(i, false)"
>
{{ t("deny") }}
</v-btn>
</div>
</span>
<span v-else>
<div>
<v-btn
@click="handleInvitation(i, true)"
class="mr-2"
icon="mdi-check-circle"
color="green"
variant="text"
>
</v-btn>
<v-btn
@click="handleInvitation(i, false)"
class="mr-2"
icon="mdi-close-circle"
color="red"
variant="text"
>
</v-btn></div
></span>
</td>
</tr>
</template>
<template v-else>
<tr>
<td
colspan="3"
class="empty-message"
> >
<v-icon </v-icon>
icon="mdi-information-outline" {{ t("no-invitations-found") }}
size="small" </td>
> </tr>
</v-icon> </template>
{{ t("no-invitations-found") }}
</td>
</tr>
</template>
</using-query-result>
</using-query-result> </using-query-result>
</tbody> </tbody>
</v-table> </v-table>
@ -469,9 +484,24 @@
<v-text-field <v-text-field
v-model="selectedCode" v-model="selectedCode"
readonly readonly
append-inner-icon="mdi-content-copy" >
@click:append-inner="copyToClipboard" <template #append>
></v-text-field> <v-btn
icon
variant="text"
@click="copyToClipboard(selectedCode, true)"
>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
<v-btn
icon
variant="text"
@click="copyToClipboard(selectedCode, true, true)"
>
<v-icon>mdi-link-variant</v-icon>
</v-btn>
</template>
</v-text-field>
<v-slide-y-transition> <v-slide-y-transition>
<div <div
v-if="copied" v-if="copied"

View file

@ -2,6 +2,7 @@
import authState from "@/services/auth/auth-service.ts"; import authState from "@/services/auth/auth-service.ts";
import TeacherClasses from "./TeacherClasses.vue"; import TeacherClasses from "./TeacherClasses.vue";
import StudentClasses from "./StudentClasses.vue"; import StudentClasses from "./StudentClasses.vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
// Determine if role is student or teacher to render correct view // Determine if role is student or teacher to render correct view
const role: string = authState.authState.activeRole!; const role: string = authState.authState.activeRole!;
@ -9,7 +10,7 @@
<template> <template>
<main> <main>
<TeacherClasses v-if="role === 'teacher'"></TeacherClasses> <TeacherClasses v-if="role === AccountType.Teacher"></TeacherClasses>
<StudentClasses v-else></StudentClasses> <StudentClasses v-else></StudentClasses>
</main> </main>
</template> </template>

View file

@ -22,6 +22,7 @@
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import QuestionNotification from "@/components/QuestionNotification.vue"; import QuestionNotification from "@/components/QuestionNotification.vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@ -235,8 +236,10 @@
</p> </p>
</template> </template>
</v-list-item> </v-list-item>
<v-list-item <v-list-itemF
v-if="query.classId && query.assignmentNo && authService.authState.activeRole === 'teacher'" v-if="
query.classId && query.assignmentNo && authService.authState.activeRole === AccountType.Teacher
"
> >
<template v-slot:default> <template v-slot:default>
<learning-path-group-selector <learning-path-group-selector
@ -245,7 +248,7 @@
v-model="forGroupQueryParam" v-model="forGroupQueryParam"
/> />
</template> </template>
</v-list-item> </v-list-itemF>
<v-divider></v-divider> <v-divider></v-divider>
<div> <div>
<using-query-result <using-query-result
@ -259,7 +262,9 @@
:title="node.title" :title="node.title"
:active="node.key === props.learningObjectHruid" :active="node.key === props.learningObjectHruid"
:key="node.key" :key="node.key"
v-if="!node.teacherExclusive || authService.authState.activeRole === 'teacher'" v-if="
!node.teacherExclusive || authService.authState.activeRole === AccountType.Teacher
"
> >
<template v-slot:prepend> <template v-slot:prepend>
<v-icon <v-icon
@ -283,7 +288,7 @@
</using-query-result> </using-query-result>
</div> </div>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-list-item v-if="authService.authState.activeRole === 'teacher'"> <v-list-item v-if="authService.authState.activeRole === AccountType.Teacher">
<template v-slot:default> <template v-slot:default>
<v-btn <v-btn
class="button-in-nav" class="button-in-nav"
@ -296,7 +301,7 @@
</v-list-item> </v-list-item>
<v-list-item> <v-list-item>
<div <div
v-if="authService.authState.activeRole === 'student' && pathIsAssignment" v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
class="assignment-indicator" class="assignment-indicator"
> >
{{ t("assignmentIndicator") }} {{ t("assignmentIndicator") }}
@ -325,7 +330,7 @@
></learning-object-view> ></learning-object-view>
</div> </div>
<div <div
v-if="authService.authState.activeRole === 'student' && pathIsAssignment" v-if="authService.authState.activeRole === AccountType.Student && pathIsAssignment"
class="question-box" class="question-box"
> >
<div class="input-wrapper"> <div class="input-wrapper">

View file

@ -17,43 +17,29 @@
</script> </script>
<template> <template>
<v-container class="search-page-container"> <div class="search-page-container d-flex flex-column align-items-center justify-center">
<v-row <div class="search-field-container">
justify="center" <learning-path-search-field class="mx-auto" />
class="mb-6" </div>
<using-query-result
:query-result="searchQueryResults"
v-slot="{ data }: { data: LearningPath[] }"
> >
<v-col <learning-paths-grid :learning-paths="data" />
cols="12" </using-query-result>
sm="8"
md="6"
lg="4"
>
<learning-path-search-field class="search-field" />
</v-col>
</v-row>
<v-row justify="center"> <div
<v-col cols="12"> v-if="!query"
<using-query-result class="empty-state-container"
:query-result="searchQueryResults" >
v-slot="{ data }: { data: LearningPath[] }" <v-empty-state
> icon="mdi-magnify"
<learning-paths-grid :learning-paths="data" /> :title="t('enterSearchTerm')"
</using-query-result> :text="t('enterSearchTermDescription')"
/>
<div </div>
v-if="!query" </div>
class="empty-state-container"
>
<v-empty-state
icon="mdi-magnify"
:title="t('enterSearchTerm')"
:text="t('enterSearchTermDescription')"
/>
</div>
</v-col>
</v-row>
</v-container>
</template> </template>
<style scoped> <style scoped>
@ -61,8 +47,7 @@
padding-top: 40px; padding-top: 40px;
padding-bottom: 40px; padding-bottom: 40px;
} }
.search-field-container {
.search-field { justify-content: center !important;
max-width: 100%;
} }
</style> </style>

View file

@ -8,8 +8,9 @@
import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data"; import type { SubmissionData } from "@/views/learning-paths/learning-object/submission-data";
import LearningObjectContentView from "@/views/learning-paths/learning-object/content/LearningObjectContentView.vue"; import LearningObjectContentView from "@/views/learning-paths/learning-object/content/LearningObjectContentView.vue";
import LearningObjectSubmissionsView from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsView.vue"; import LearningObjectSubmissionsView from "@/views/learning-paths/learning-object/submissions/LearningObjectSubmissionsView.vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const _isStudent = computed(() => authService.authState.activeRole === "student"); const _isStudent = computed(() => authService.authState.activeRole === AccountType.Student);
const props = defineProps<{ const props = defineProps<{
hruid: string; hruid: string;

View file

@ -11,6 +11,7 @@
import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { AccountType } from "@dwengo-1/common/util/account-types";
const { t } = useI18n(); const { t } = useI18n();
@ -31,7 +32,7 @@
mutate: submitSolution, mutate: submitSolution,
} = useCreateSubmissionMutation(); } = useCreateSubmissionMutation();
const isStudent = computed(() => authService.authState.activeRole === "student"); const isStudent = computed(() => authService.authState.activeRole === AccountType.Student);
const isSubmitDisabled = computed(() => { const isSubmitDisabled = computed(() => {
if (!props.submissionData || props.submissions === undefined) { if (!props.submissionData || props.submissions === undefined) {

1
package-lock.json generated
View file

@ -93,6 +93,7 @@
"name": "dwengo-1-frontend", "name": "dwengo-1-frontend",
"version": "0.2.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@dwengo-1/common": "^0.2.0",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.69.0",
"@tanstack/vue-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",