diff --git a/backend/.env.example b/backend/.env.example index fdbbca00b..d420b88b5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,3 +32,5 @@ HY_ORGANIZATION_ID=x UPDATE_USER_SECRET=secret BACKEND_URL=http://localhost:4000 + +JWT_SECRET=supersecretkey diff --git a/backend/accessControl.ts b/backend/accessControl.ts index d936cf79f..a2a0cdbc0 100644 --- a/backend/accessControl.ts +++ b/backend/accessControl.ts @@ -74,6 +74,16 @@ export const isCourseOwner = return Boolean(ownership) } +export const isAdminOrCourseOwner = + (course_id: string): AuthorizeFunction => + async (root, args, ctx, info) => { + if (isAdmin(root, args, ctx, info)) { + return true + } + + return await isCourseOwner(course_id)(root, args, ctx, info) + } + export const or = (...predicates: AuthorizeFunction[]): AuthorizeFunction => (...params) => diff --git a/backend/api/index.ts b/backend/api/index.ts index 9222e4297..5af9b7045 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -18,6 +18,11 @@ export function apiRouter(ctx: ApiContext) { return Router() .get("/completions/:slug", completionController.completions) + .get( + "/completions/:courseId/csv/token", + completionController.completionsCSVToken, + ) + .get("/completions/:courseId/csv", completionController.completionsCSV) .get("/completionTiers/:slug", completionController.completionTiers) .get( "/completionInstructions/:slug/:language", diff --git a/backend/api/routes/completions.ts b/backend/api/routes/completions.ts index 7416e2ba5..2f8e05414 100644 --- a/backend/api/routes/completions.ts +++ b/backend/api/routes/completions.ts @@ -1,6 +1,9 @@ +import { stringify } from "csv-stringify/sync" import { Request, Response } from "express" import JSONStream from "JSONStream" +import jwt, { Secret } from "jsonwebtoken" import { chunk, omit } from "lodash" +import * as XLSX from "xlsx" import * as yup from "yup" import { @@ -14,6 +17,7 @@ import { import { generateUserCourseProgress } from "../../bin/kafkaConsumer/common/userCourseProgress/generateUserCourseProgress" import { err, isDefined } from "../../util" import { ApiContext, Controller } from "../types" +import { requireAdminOrCourseOwner } from "../utils" const languageMap: Record = { en: "en_US", @@ -21,6 +25,19 @@ const languageMap: Record = { fi: "fi_FI", } +// JWT secret for signing download tokens +const JWT_SECRET = process.env.JWT_SECRET as Secret + +if (!JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required") +} + +interface DownloadTokenPayload { + courseId: string + fromDate?: string + format?: "csv" | "excel" +} + interface RegisterCompletionInput { completion_id: string student_number: string @@ -96,6 +113,174 @@ export class CompletionController extends Controller { return // NOSONAR } + completionsCSVToken = async ( + req: Request<{ courseId: string }>, + res: Response, + ) => { + const { courseId } = req.params + const { fromDate, format } = req.query + + const authRes = await requireAdminOrCourseOwner(courseId, this.ctx)( + req, + res, + ) + + if (authRes.isErr()) { + return authRes.error + } + + const course = await this.ctx.prisma.course.findUnique({ + where: { id: courseId }, + }) + + if (!course) { + return res.status(404).json({ message: "Course not found" }) + } + + // Generate a signed JWT token valid for 30 seconds + const payload: DownloadTokenPayload = { + courseId, + fromDate: typeof fromDate === "string" ? fromDate : undefined, + format: format === "excel" ? "excel" : "csv", + } + + const token = jwt.sign(payload, JWT_SECRET, { + expiresIn: "30s", + }) + + return res.status(200).json({ token }) + } + + completionsCSV = async ( + req: Request<{ courseId: string }>, + res: Response, + ) => { + const { courseId } = req.params + const { token } = req.query + const { knex } = this.ctx + + // Validate token + if (!token || typeof token !== "string") { + return res.status(401).json({ message: "Invalid or missing token" }) + } + + let tokenData: DownloadTokenPayload + try { + tokenData = jwt.verify(token, JWT_SECRET) as DownloadTokenPayload + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return res.status(401).json({ message: "Token expired" }) + } + return res.status(401).json({ message: "Invalid token" }) + } + + if (tokenData.courseId !== courseId) { + return res + .status(403) + .json({ message: "Token not valid for this course" }) + } + + const fromDate = tokenData.fromDate + const format = tokenData.format ?? "csv" + + const course = await this.ctx.prisma.course.findUnique({ + where: { id: courseId }, + }) + + if (!course) { + return res.status(404).json({ message: "Course not found" }) + } + + let query = knex + .select( + "u.id", + "com.email", + "u.first_name", + "u.last_name", + "com.completion_date", + "com.completion_language", + "com.grade", + ) + .from("completion as com") + .join("course as c", "com.course_id", "c.id") + .join("user as u", "com.user_id", "u.id") + .where("c.id", course.completions_handled_by_id ?? course.id) + .distinct("u.id", "com.course_id") + .orderBy("com.completion_date", "asc") + .orderBy("u.last_name", "asc") + .orderBy("u.first_name", "asc") + .orderBy("u.id", "asc") + + if (fromDate && typeof fromDate === "string") { + try { + const date = new Date(fromDate) + query = query.where("com.completion_date", ">=", date) + } catch (e) { + return res.status(400).json({ message: "Invalid date format" }) + } + } + + const completions = await query + + const headers = [ + "User ID", + "Email", + "First Name", + "Last Name", + "Completion Date", + "Completion Language", + "Grade", + ] + + const rows = completions.map((row) => [ + row.id, + row.email, + row.first_name, + row.last_name, + row.completion_date, + row.completion_language, + row.grade, + ]) + + if (format === "excel") { + // Generate Excel file + const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows]) + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, worksheet, "Completions") + + const excelBuffer = XLSX.write(workbook, { + type: "buffer", + bookType: "xlsx", + }) + + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + res.setHeader( + "Content-Disposition", + `attachment; filename="completions_${ + fromDate ? fromDate.toString().split("T")[0] : "all" + }.xlsx"`, + ) + + return res.status(200).send(excelBuffer) + } + + // Default CSV format + const csvContent = stringify([headers, ...rows]) + + res.setHeader("Content-Type", "text/csv") + res.setHeader( + "Content-Disposition", + `attachment; filename="completions_${ + fromDate ? fromDate.toString().split("T")[0] : "all" + }.csv"`, + ) + + return res.status(200).send(csvContent) + } + completionInstructions = async ( req: Request<{ slug: string; language: string }>, res: Response, diff --git a/backend/api/utils.ts b/backend/api/utils.ts index f6964a4f6..e172eeb74 100644 --- a/backend/api/utils.ts +++ b/backend/api/utils.ts @@ -71,6 +71,40 @@ export function requireAdmin(ctx: BaseContext) { } } +export function requireAdminOrCourseOwner(courseId: string, ctx: BaseContext) { + return async function ( + req: Request, + res: Response, + ): Promise> { + const getUserResult = await getUser(ctx)(req, res) + + if (getUserResult.isErr()) { + return err(getUserResult.error) + } + + const { user, details } = getUserResult.value + + // Allow if user is admin + if (details.administrator) { + return ok(true) + } + + // Check if user has course ownership for this course + const ownership = await ctx.knex + .select("id") + .from("course_ownership") + .where("user_id", user.id) + .andWhere("course_id", courseId) + .first() + + if (ownership) { + return ok(true) + } + + return err(res.status(401).json({ message: "unauthorized" })) + } +} + export function getUser({ knex, logger }: BaseContext) { return async function ( req: Request, diff --git a/backend/package-lock.json b/backend/package-lock.json index 3b4b55116..27a471e82 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,12 +19,14 @@ "@sentry/integrations": "^6.19.7", "@sentry/node": "^6.19.7", "@types/graphql-upload": "^8.0.12", + "@types/jsonwebtoken": "^9.0.10", "axios": "^1.4.0", "body-parser": "^1.20.2", "compression": "^1.7.4", "concurrently": "^8.2.0", "cors": "^2.8.5", "cross-env": "^7.0.3", + "csv-stringify": "^6.6.0", "dataloader": "^2.2.3", "dotenv-safe": "^8.2.0", "express": "^4.17.1", @@ -35,6 +37,7 @@ "helmet": "^7.0.0", "json-parse-even-better-errors": "^3.0.0", "JSONStream": "^1.3.5", + "jsonwebtoken": "^9.0.3", "knex": "^2.5.1", "lodash": "^4.17.21", "luxon": "^3.3.0", @@ -59,6 +62,7 @@ "winston": "^3.10.0", "winston-sentry-log": "^1.0.26", "ws": "^8.13.0", + "xlsx": "^0.18.5", "yup": "^1.2.0" }, "devDependencies": { @@ -4964,6 +4968,15 @@ "@types/node": "*" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/keygrip": { "version": "1.0.2", "license": "MIT" @@ -5169,6 +5182,14 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.1", "license": "MIT", @@ -5720,7 +5741,8 @@ }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", - "license": "BSD-3-Clause" + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, "node_modules/buffer-from": { "version": "1.1.1", @@ -5801,6 +5823,18 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "license": "MIT", @@ -5987,6 +6021,14 @@ "node": ">= 0.12.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -6334,6 +6376,11 @@ "node": ">=8" } }, + "node_modules/csv-stringify": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.6.0.tgz", + "integrity": "sha512-YW32lKOmIBgbxtu3g5SaiqWNwa/9ISQt2EcgOq0+RAIFufFp9is6tqNnKahqE5kuKvrnYAzs28r+s6pXJR8Vcw==" + }, "node_modules/d": { "version": "1.0.1", "license": "ISC", @@ -7189,6 +7236,14 @@ "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.0.tgz", "integrity": "sha512-bLq+KgbiXdTEoT1zcARrWEpa5z6A/8b7PcDW7Gef3NSisQ+VS7ll2Xbf1E+xsgik0rWub/8u0qP/iTTjj+PhxQ==" }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "0.5.2", "license": "MIT", @@ -8880,20 +8935,54 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jwa": { - "version": "2.0.0", - "license": "MIT", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { - "version": "4.0.0", - "license": "MIT", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -9099,16 +9188,46 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "license": "MIT" @@ -11312,6 +11431,17 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "license": "MIT", @@ -12383,6 +12513,22 @@ "node": ">= 6.4.0" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "license": "MIT", @@ -12433,6 +12579,26 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml": { "version": "1.0.1", "dev": true, diff --git a/backend/package.json b/backend/package.json index c9c09307c..105077a8d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -72,12 +72,14 @@ "@sentry/integrations": "^6.19.7", "@sentry/node": "^6.19.7", "@types/graphql-upload": "^8.0.12", + "@types/jsonwebtoken": "^9.0.10", "axios": "^1.4.0", "body-parser": "^1.20.2", "compression": "^1.7.4", "concurrently": "^8.2.0", "cors": "^2.8.5", "cross-env": "^7.0.3", + "csv-stringify": "^6.6.0", "dataloader": "^2.2.3", "dotenv-safe": "^8.2.0", "express": "^4.17.1", @@ -88,6 +90,7 @@ "helmet": "^7.0.0", "json-parse-even-better-errors": "^3.0.0", "JSONStream": "^1.3.5", + "jsonwebtoken": "^9.0.3", "knex": "^2.5.1", "lodash": "^4.17.21", "luxon": "^3.3.0", @@ -112,6 +115,7 @@ "winston": "^3.10.0", "winston-sentry-log": "^1.0.26", "ws": "^8.13.0", + "xlsx": "^0.18.5", "yup": "^1.2.0" }, "devDependencies": { diff --git a/backend/schema/Completion/mutations.ts b/backend/schema/Completion/mutations.ts index d47190081..68d513d8d 100644 --- a/backend/schema/Completion/mutations.ts +++ b/backend/schema/Completion/mutations.ts @@ -5,12 +5,52 @@ import { v4 as uuidv4 } from "uuid" import { Completion } from "@prisma/client" import { User } from "@sentry/node" -import { isAdmin, isUser, or, Role } from "../../accessControl" +import { + isAdmin, + isAdminOrCourseOwner, + isUser, + or, + Role, +} from "../../accessControl" import { generateUserCourseProgress } from "../../bin/kafkaConsumer/common/userCourseProgress/generateUserCourseProgress" import { GraphQLUserInputError } from "../../lib/errors" import { isDefined } from "../../util" import { ConflictError } from "../common" +async function authorizeByCourseIdentifier( + root: any, + args: { + course_id?: string | null + course_slug?: string | null + slug?: string | null + }, + ctx: any, + info: any, +) { + // If user is admin, they're authorized regardless of course existence + if (isAdmin(root, args, ctx, info)) { + return true + } + + // For non-admins, check course ownership + const courseId = args.course_id + const courseSlug = args.course_slug ?? args.slug + + const course = await ctx.prisma.course.findUniqueOrAlias({ + where: { + id: courseId ?? undefined, + slug: courseSlug ?? undefined, + }, + select: { id: true }, + }) + + if (!course) { + return false + } + + return await isAdminOrCourseOwner(course.id)(root, args, ctx, info) +} + export const CompletionMutations = extendType({ type: "Mutation", definition(t) { @@ -25,7 +65,9 @@ export const CompletionMutations = extendType({ completion_language: stringArg(), tier: intArg(), }, - authorize: isAdmin, + authorize: async (root, args, ctx, info) => { + return await isAdminOrCourseOwner(args.course)(root, args, ctx, info) + }, resolve: (_, args, ctx) => { const { user_upstream_id, @@ -65,8 +107,14 @@ export const CompletionMutations = extendType({ ["course_id", "course_slug"], ) } + if (course_id && course_slug) { + throw new GraphQLUserInputError( + "must provide exactly one of course_id or course_slug", + ["course_id", "course_slug"], + ) + } }, - authorize: isAdmin, + authorize: authorizeByCourseIdentifier, resolve: async (_, args, ctx) => { const { course_id, course_slug } = args @@ -171,15 +219,22 @@ export const CompletionMutations = extendType({ course_id: idArg(), slug: stringArg(), }, - authorize: isAdmin, - resolve: async (_, { course_id: id, slug }, ctx) => { - if ((!id && !slug) || (id && slug)) { + validate: (_, { course_id, slug }) => { + if (!course_id && !slug) { + throw new GraphQLUserInputError("must provide course_id or slug", [ + "course_id", + "slug", + ]) + } + if (course_id && slug) { throw new GraphQLUserInputError( - "must provide exactly one of course_id or slug!", + "must provide exactly one of course_id or slug", ["course_id", "slug"], ) } - + }, + authorize: authorizeByCourseIdentifier, + resolve: async (_, { course_id: id, slug }, ctx) => { const course = await ctx.prisma.course.findUniqueOrAlias({ where: { id: id ?? undefined, diff --git a/backend/schema/Completion/queries.ts b/backend/schema/Completion/queries.ts index a19660cbb..420cd115b 100644 --- a/backend/schema/Completion/queries.ts +++ b/backend/schema/Completion/queries.ts @@ -5,7 +5,12 @@ import { extendType, idArg, intArg, nonNull, stringArg } from "nexus" import { findManyCursorConnection } from "@devoxa/prisma-relay-cursor-connection" import { Prisma } from "@prisma/client" -import { isAdmin, isOrganization, or } from "../../accessControl" +import { + isAdmin, + isAdminOrCourseOwner, + isOrganization, + or, +} from "../../accessControl" import { GraphQLForbiddenError, GraphQLUserInputError } from "../../lib/errors" import { buildUserSearch } from "../../util" @@ -22,7 +27,25 @@ export const CompletionQueries = extendType({ last: intArg(), before: idArg(), }, - authorize: or(isOrganization, isAdmin), + authorize: async (root, args, ctx, info) => { + if ( + isOrganization(root, args, ctx, info) || + isAdmin(root, args, ctx, info) + ) { + return true + } + + const course = await ctx.prisma.course.findUniqueOrAlias({ + where: { slug: args.course }, + select: { id: true }, + }) + + if (!course) { + return false + } + + return await isAdminOrCourseOwner(course.id)(root, args, ctx, info) + }, resolve: async (_, args, ctx) => { const { first, last, completion_language } = args const { course: slug } = args diff --git a/backend/schema/Course/model.ts b/backend/schema/Course/model.ts index f3d1de279..5a41b4201 100644 --- a/backend/schema/Course/model.ts +++ b/backend/schema/Course/model.ts @@ -2,7 +2,7 @@ import { booleanArg, intArg, list, nonNull, objectType, stringArg } from "nexus" import { Prisma } from "@prisma/client" -import { isAdmin } from "../../accessControl" +import { isAdmin, isAdminOrCourseOwner } from "../../accessControl" import { GraphQLForbiddenError, GraphQLUserInputError } from "../../lib/errors" import { filterNullFields } from "../../util" @@ -82,7 +82,9 @@ export const Course = objectType({ user_id: stringArg(), user_upstream_id: intArg(), }, - authorize: isAdmin, + authorize: async (parent, args, ctx, info) => { + return await isAdminOrCourseOwner(parent.id)({}, args, ctx, info) + }, validate: (_, { user_id, user_upstream_id }) => { if (!user_id && !user_upstream_id) { throw new GraphQLUserInputError("needs user_id or user_upstream_id", [ diff --git a/backend/schema/Course/mutations.ts b/backend/schema/Course/mutations.ts index 790c687be..a035b636e 100644 --- a/backend/schema/Course/mutations.ts +++ b/backend/schema/Course/mutations.ts @@ -5,7 +5,7 @@ import { NexusGenInputs } from "nexus-typegen" import { Course, CourseSponsor, Prisma, StudyModule, Tag } from "@prisma/client" -import { isAdmin } from "../../accessControl" +import { isAdmin, isAdminOrCourseOwner } from "../../accessControl" import { Context } from "../../context" import { GraphQLUserInputError } from "../../lib/errors" import { invalidateAllGraphqlCachedQueries } from "../../middlewares/expressGraphqlCache" @@ -169,7 +169,24 @@ export const CourseMutations = extendType({ }), ), }, - authorize: isAdmin, + authorize: async (root, { course }, ctx, info) => { + if (!course.slug) { + return false + } + const existingCourse = await ctx.prisma.course.findUnique({ + where: { slug: course.slug }, + select: { id: true }, + }) + if (!existingCourse) { + return false + } + return await isAdminOrCourseOwner(existingCourse.id)( + root, + { course }, + ctx, + info, + ) + }, resolve: async (_, { course }, ctx: Context) => { const { id, @@ -306,7 +323,24 @@ export const CourseMutations = extendType({ id: idArg(), slug: stringArg(), }, - authorize: isAdmin, + authorize: async (root, { id, slug }, ctx, info) => { + const course = await ctx.prisma.course.findUnique({ + where: { + id: id ?? undefined, + slug: slug ?? undefined, + }, + select: { id: true }, + }) + if (!course) { + return false + } + return await isAdminOrCourseOwner(course.id)( + root, + { id, slug }, + ctx, + info, + ) + }, validate: (_, { id, slug }) => { if (!id && !slug) { throw new GraphQLUserInputError("must provide id or slug") diff --git a/frontend/components/CompletionsDownloadButton.tsx b/frontend/components/CompletionsDownloadButton.tsx new file mode 100644 index 000000000..87794f43b --- /dev/null +++ b/frontend/components/CompletionsDownloadButton.tsx @@ -0,0 +1,202 @@ +import { useCallback, useId, useState } from "react" + +import { DateTime } from "luxon" + +import DownloadIcon from "@mui/icons-material/Download" +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, +} from "@mui/material" +import { styled } from "@mui/material/styles" +import { DatePicker } from "@mui/x-date-pickers/DatePicker" + +import { useAlertContext } from "/contexts/AlertContext" +import { useTranslator } from "/hooks/useTranslator" +import { getAccessToken } from "/lib/authentication" +import CompletionsTranslations from "/translations/completions" + +const StyledDialog = styled(Dialog)` + & .MuiDialog-paper { + min-width: 400px; + padding: 2rem; + } +` + +const StyledDialogTitle = styled(DialogTitle)` + padding-top: 0 !important; +` + +const StyledDialogContent = styled(DialogContent)`` + +const StyledDialogActions = styled(DialogActions)` + gap: 0.5rem; +` + +const StyledDatePicker = styled(DatePicker)` + margin-top: 0.5rem; +` + +interface CompletionsDownloadButtonProps { + courseId: string +} + +const CompletionsDownloadButton = ({ + courseId, +}: CompletionsDownloadButtonProps) => { + const t = useTranslator(CompletionsTranslations) + const { addAlert } = useAlertContext() + const [dialogOpen, setDialogOpen] = useState(false) + const [selectedDate, setSelectedDate] = useState(null) + const [format, setFormat] = useState<"csv" | "excel">("csv") + const [isLoading, setIsLoading] = useState(false) + const dialogTitleId = useId() + + const handleOpenDialog = useCallback(() => { + setDialogOpen(true) + }, []) + + const handleCloseDialog = useCallback(() => { + setDialogOpen(false) + setSelectedDate(null) + setFormat("csv") + }, []) + + const handleDownload = useCallback(async () => { + setIsLoading(true) + try { + // First, request a single-use token + const tokenParams = new URLSearchParams() + if (selectedDate) { + const isoDate = selectedDate.toISODate() + if (!isoDate) { + throw new Error("Failed to convert date to ISO format") + } + tokenParams.append("fromDate", isoDate) + } + tokenParams.append("format", format) + + const accessToken = getAccessToken(undefined) + + const tokenResponse = await fetch( + `/api/completions/${courseId}/csv/token?${tokenParams.toString()}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + + if (!tokenResponse.ok) { + throw new Error("Failed to generate download token") + } + + const { token } = await tokenResponse.json() + + // Use the token to download the CSV + const link = document.createElement("a") + link.href = `/api/completions/${courseId}/csv?token=${token}` + link.click() + + addAlert({ + title: t("downloadCompletionsSuccess"), + message: t("downloadCompletionsSuccessMessage"), + severity: "success", + }) + + setDialogOpen(false) + setSelectedDate(null) + setFormat("csv") + } catch (error) { + addAlert({ + title: t("downloadCompletionsError"), + message: + error instanceof Error + ? error.message + : t("downloadCompletionsErrorMessage"), + severity: "error", + }) + } finally { + setIsLoading(false) + } + }, [selectedDate, format, courseId, t, addAlert]) + + return ( + <> + + + + + {t("downloadCompletionsDialogTitle")} + + + setSelectedDate(date as DateTime | null)} + slotProps={{ + textField: { + fullWidth: true, + }, + }} + /> + + {t("downloadCompletionsFileFormat")} + setFormat(e.target.value as "csv" | "excel")} + > + } + label={t("downloadCompletionsFormatCsv")} + /> + } + label={t("downloadCompletionsFormatExcel")} + /> + + + + + + + + + + ) +} + +export default CompletionsDownloadButton diff --git a/frontend/contexts/LoginStateContext.tsx b/frontend/contexts/LoginStateContext.tsx index 8698dd789..b9464b0ab 100644 --- a/frontend/contexts/LoginStateContext.tsx +++ b/frontend/contexts/LoginStateContext.tsx @@ -41,6 +41,13 @@ export function useLoginStateContext() { const reducer = (state: LoginState, action: any) => { switch (action.type) { + case "sync": + return { + ...state, + loggedIn: action.payload.loggedIn, + admin: action.payload.admin, + currentUser: action.payload.currentUser, + } case "logInOrOut": return { ...state, @@ -77,13 +84,11 @@ export const LoginStateProvider = React.memo(function LoginStateProvider({ }) useEffect(() => { - if (currentUser !== state.currentUser) { - dispatch({ - type: "updateUser", - payload: { user: currentUser, admin }, - }) - } - }, [currentUser]) + dispatch({ + type: "sync", + payload: { loggedIn, admin, currentUser }, + }) + }, [loggedIn, admin, currentUser]) const loginStateContextValue = useMemo( () => ({ diff --git a/frontend/hooks/useQueryParameter.tsx b/frontend/hooks/useQueryParameter.tsx index de4ac97da..6608e2f06 100644 --- a/frontend/hooks/useQueryParameter.tsx +++ b/frontend/hooks/useQueryParameter.tsx @@ -22,7 +22,7 @@ export function useQueryParameter( const router = useRouter() - if (!router) { + if (!router || !router.isReady) { return array ? [] : "" } diff --git a/frontend/lib/authentication.ts b/frontend/lib/authentication.ts index 6c5e782ec..49ddd9fad 100644 --- a/frontend/lib/authentication.ts +++ b/frontend/lib/authentication.ts @@ -10,13 +10,21 @@ const tmcClient = new TmcClient( "2ddf92a15a31f87c1aabb712b7cfd1b88f3465465ec475811ccce6febb1bad28", ) -export const isSignedIn = (ctx?: NextContext) => { +export const isSignedIn = (ctx?: NextContext): boolean => { + if (typeof window !== "undefined") { + const match = document.cookie.match(/(?:^|; )access_token=([^;]*)/) + return Boolean(match?.[1]) + } const cookies = nookies.get(ctx) const accessToken = cookies["access_token"] return typeof accessToken === "string" } -export const isAdmin = (ctx?: NextContext) => { +export const isAdmin = (ctx?: NextContext): boolean => { + if (typeof window !== "undefined") { + const match = document.cookie.match(/(?:^|; )admin=([^;]*)/) + return match?.[1] === "true" + } const cookies = nookies.get(ctx) const admin = cookies["admin"] return admin === "true" @@ -87,7 +95,13 @@ export const signOut = async (apollo: ApolloClient, cb: () => void) => { }, 100) } -export const getAccessToken = (ctx?: NextContext) => { +export const getAccessToken = (ctx?: NextContext): string | undefined => { + if (typeof window !== "undefined") { + const match = document.cookie.match(/(?:^|; )access_token=([^;]*)/) + if (match?.[1]) { + return match[1] + } + } const cookies = nookies.get(ctx) return cookies["access_token"] } diff --git a/frontend/lib/with-admin-or-course-owner.tsx b/frontend/lib/with-admin-or-course-owner.tsx new file mode 100644 index 000000000..8fc751e60 --- /dev/null +++ b/frontend/lib/with-admin-or-course-owner.tsx @@ -0,0 +1,96 @@ +import React, { ComponentType, useEffect } from "react" + +import { useRouter } from "next/router" + +import { gql, useQuery } from "@apollo/client" + +import AdminError from "/components/Dashboard/AdminError" +import Spinner from "/components/Spinner" +import { useAuth } from "/hooks/useAuth" + +const COURSE_OWNER_CHECK = gql` + query CourseOwnerCheck($slug: String!) { + course(slug: $slug) { + id + slug + name + } + currentUser { + id + administrator + course_ownerships { + course_id + } + } + } +` + +function withAdminOrCourseOwner

( + Component: ComponentType< + P & { admin?: boolean; signedIn?: boolean; baseUrl?: string } + >, +) { + const WithAdminOrCourseOwner = (props: P) => { + const router = useRouter() + const { signedIn, loading, admin } = useAuth() + const baseUrl = router.pathname.includes("_old") ? "/_old" : "" + const rawSlug = router.query.slug + const slug = Array.isArray(rawSlug) ? rawSlug[0] ?? "" : rawSlug ?? "" + + const { + data, + loading: ownershipLoading, + error, + } = useQuery(COURSE_OWNER_CHECK, { + variables: { slug }, + skip: !signedIn || !slug, + fetchPolicy: "network-only", + }) + + const isOwner = Boolean( + data?.course?.id && + data?.currentUser?.course_ownerships?.some( + (o: { course_id: string | null }) => o.course_id === data.course?.id, + ), + ) + + useEffect(() => { + if (!loading && !signedIn) { + router.push(`${baseUrl}/sign-in`) + } + }, [loading, signedIn, router, baseUrl]) + + if (loading || ownershipLoading || !slug) { + return + } + + if (!signedIn) { + return

Redirecting...
+ } + + if (error) { + return + } + + if (!admin && !isOwner) { + return + } + + return ( + + ) + } + + WithAdminOrCourseOwner.displayName = `withAdminOrCourseOwner(${ + Component.displayName ?? Component.name ?? "AnonymousComponent" + })` + + return WithAdminOrCourseOwner +} + +export default withAdminOrCourseOwner diff --git a/frontend/next.config.js b/frontend/next.config.js index 57c838763..437ffc482 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -48,6 +48,18 @@ const nextConfiguration = (_phase) => ({ ], }, trailingSlash: true, + async rewrites() { + // In development, proxy API requests to the backend server + if (!isProduction) { + return [ + { + source: "/api/:path*", + destination: "http://localhost:4000/api/:path*", + }, + ] + } + return [] + }, i18n: { locales: ["en", "fi"], defaultLocale: "fi", diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index 049f40414..1c6859a53 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -7,6 +7,8 @@ import Script from "next/script" import { CssBaseline } from "@mui/material" import { ThemeProvider } from "@mui/material/styles" +import { LocalizationProvider } from "@mui/x-date-pickers" +import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon" import DynamicLayout from "/components/DynamicLayout" import AppContextProvider from "/contexts/AppContextProvider" @@ -71,14 +73,16 @@ export function MyApp({ Component, pageProps, deviceType }: MyAppProps) { - - - - - - - - + + + + + + + + + + ) diff --git a/frontend/pages/_old/courses/[slug]/completions.tsx b/frontend/pages/_old/courses/[slug]/completions.tsx index 3ac857759..489525caa 100644 --- a/frontend/pages/_old/courses/[slug]/completions.tsx +++ b/frontend/pages/_old/courses/[slug]/completions.tsx @@ -8,6 +8,7 @@ import { TextField } from "@mui/material" import { styled } from "@mui/material/styles" import { useEventCallback } from "@mui/material/utils" +import CompletionsDownloadButton from "/components/CompletionsDownloadButton" import { WideContainer } from "/components/Container" import CompletionsList from "/components/Dashboard/CompletionsList" import DashboardTabBar from "/components/Dashboard/DashboardTabBar" @@ -20,7 +21,7 @@ import { useBreadcrumbs } from "/hooks/useBreadcrumbs" import useIsOld from "/hooks/useIsOld" import { useQueryParameter } from "/hooks/useQueryParameter" import { useTranslator } from "/hooks/useTranslator" -import withAdmin from "/lib/with-admin" +import withAdminOrCourseOwner from "/lib/with-admin-or-course-owner" import CoursesTranslations from "/translations/courses" import { CourseFromSlugDocument } from "/graphql/generated" @@ -32,6 +33,39 @@ const ContentArea = styled("div")` margin: auto; ` +const TitleContainer = styled("div")` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + gap: 1rem; + max-width: 60em; + margin-left: auto; + margin-right: auto; + + & h1 { + flex-shrink: 1; + min-width: 0; + } + + & button { + flex-shrink: 0; + } + + @media (max-width: 600px) { + flex-direction: column; + align-items: flex-start; + + & h1 { + width: 100%; + } + + & button { + align-self: flex-start; + } + } +` + const Completions = () => { const isOld = useIsOld() const baseUrl = isOld ? "/_old" : "/admin" @@ -109,9 +143,12 @@ const Completions = () => { - - {data.course.name} - + + + {data.course.name} + + + { Completions.displayName = "Completions" -export default withAdmin(Completions) +export default withAdminOrCourseOwner(Completions) diff --git a/frontend/pages/_old/courses/[slug]/edit.tsx b/frontend/pages/_old/courses/[slug]/edit.tsx index 3be454502..e52c94692 100644 --- a/frontend/pages/_old/courses/[slug]/edit.tsx +++ b/frontend/pages/_old/courses/[slug]/edit.tsx @@ -18,7 +18,7 @@ import { useEditorCourses } from "/hooks/useEditorCourses" import useIsOld from "/hooks/useIsOld" import { useQueryParameter } from "/hooks/useQueryParameter" import { useTranslator } from "/hooks/useTranslator" -import withAdmin from "/lib/with-admin" +import withAdminOrCourseOwner from "/lib/with-admin-or-course-owner" import CoursesTranslations from "/translations/courses" const ErrorContainer = styled(Paper)` @@ -140,4 +140,4 @@ const EditCourse = () => { ) } -export default withAdmin(EditCourse) +export default withAdminOrCourseOwner(EditCourse) diff --git a/frontend/pages/_old/courses/[slug]/index.tsx b/frontend/pages/_old/courses/[slug]/index.tsx index 9e0cdd53a..70ec40158 100644 --- a/frontend/pages/_old/courses/[slug]/index.tsx +++ b/frontend/pages/_old/courses/[slug]/index.tsx @@ -24,7 +24,7 @@ import { useBreadcrumbs } from "/hooks/useBreadcrumbs" import useIsOld from "/hooks/useIsOld" import { useQueryParameter } from "/hooks/useQueryParameter" import { useTranslator } from "/hooks/useTranslator" -import withAdmin from "/lib/with-admin" +import withAdminOrCourseOwner from "/lib/with-admin-or-course-owner" import CoursesTranslations from "/translations/courses" import { @@ -252,4 +252,4 @@ const Course = () => { ) } -export default withAdmin(Course) +export default withAdminOrCourseOwner(Course) diff --git a/frontend/pages/_old/courses/[slug]/manual-completions.tsx b/frontend/pages/_old/courses/[slug]/manual-completions.tsx index d6a31fe29..e134061d3 100644 --- a/frontend/pages/_old/courses/[slug]/manual-completions.tsx +++ b/frontend/pages/_old/courses/[slug]/manual-completions.tsx @@ -25,7 +25,7 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider" import { useBreadcrumbs } from "/hooks/useBreadcrumbs" import useIsOld from "/hooks/useIsOld" import { useQueryParameter } from "/hooks/useQueryParameter" -import withAdmin from "/lib/with-admin" +import withAdminOrCourseOwner from "/lib/with-admin-or-course-owner" import { AddManualCompletionDocument, @@ -280,4 +280,4 @@ const ManualCompletions = () => { ) } -export default withAdmin(ManualCompletions) +export default withAdminOrCourseOwner(ManualCompletions) diff --git a/frontend/pages/_old/courses/[slug]/points.tsx b/frontend/pages/_old/courses/[slug]/points.tsx index af8f83df0..78b647b7a 100644 --- a/frontend/pages/_old/courses/[slug]/points.tsx +++ b/frontend/pages/_old/courses/[slug]/points.tsx @@ -13,7 +13,7 @@ import { useBreadcrumbs } from "/hooks/useBreadcrumbs" import useIsOld from "/hooks/useIsOld" import { useQueryParameter } from "/hooks/useQueryParameter" import { useTranslator } from "/hooks/useTranslator" -import withAdmin from "/lib/with-admin" +import withAdminOrCourseOwner from "/lib/with-admin-or-course-owner" import CoursesTranslations from "/translations/courses" import { CourseFromSlugDocument } from "/graphql/generated" @@ -79,4 +79,4 @@ const Points = () => { ) } -export default withAdmin(Points) +export default withAdminOrCourseOwner(Points) diff --git a/frontend/translations/completions/en.ts b/frontend/translations/completions/en.ts index e3b66f876..15d6c2b5a 100644 --- a/frontend/translations/completions/en.ts +++ b/frontend/translations/completions/en.ts @@ -17,4 +17,18 @@ export default { certificateGeneratedTitle: "Certificate generated", certificateGeneratedMessage: "Your certificate was generated successfully! Press 'Show your certificate' to view it.", + downloadCompletions: "Download Completions", + downloadCompletionsDialogTitle: "Download Completions", + downloadCompletionsDateLabel: "From Date (optional)", + downloadCompletionsCancel: "Cancel", + downloadCompletionsDownload: "Download", + downloadCompletionsSuccess: "Download successful", + downloadCompletionsSuccessMessage: + "Your completions file has been downloaded successfully.", + downloadCompletionsError: "Download failed", + downloadCompletionsErrorMessage: + "An error occurred while downloading completions. Please try again.", + downloadCompletionsFileFormat: "File Format", + downloadCompletionsFormatCsv: "CSV", + downloadCompletionsFormatExcel: "Excel (.xlsx)", } as const diff --git a/frontend/translations/completions/fi.ts b/frontend/translations/completions/fi.ts index c1742f027..dc5681c39 100644 --- a/frontend/translations/completions/fi.ts +++ b/frontend/translations/completions/fi.ts @@ -18,4 +18,18 @@ export default { certificateGeneratedTitle: "Sertifikaatti generoitu", certificateGeneratedMessage: "Sertifikaattisi on nyt generoitu! Paina 'Näytä sertifikaatti' nähdäksesi sen.", + downloadCompletions: "Lataa suoritukset", + downloadCompletionsDialogTitle: "Lataa suoritukset", + downloadCompletionsDateLabel: "Alkamispäivä (valinnainen)", + downloadCompletionsCancel: "Peruuta", + downloadCompletionsDownload: "Lataa", + downloadCompletionsSuccess: "Lataus onnistui", + downloadCompletionsSuccessMessage: + "Suorituksien tiedosto on ladattu onnistuneesti.", + downloadCompletionsError: "Lataus epäonnistui", + downloadCompletionsErrorMessage: + "Virhe suorituksien lataamisessa. Yritä uudelleen.", + downloadCompletionsFileFormat: "Tiedostomuoto", + downloadCompletionsFormatCsv: "CSV", + downloadCompletionsFormatExcel: "Excel (.xlsx)", } as const