From 309dc1c7dff13875cd35867014e5bb26d9713930 Mon Sep 17 00:00:00 2001 From: Markus Brueckner Date: Thu, 9 Jan 2025 21:59:58 +0100 Subject: [PATCH] Implement shared survey access. This revamps the whole access control system to use a separate ACL instead of relying on the "owner" field of the survey, allowing more granular definitions of access to surveys (cloning, editing, viewing results etc.) Closes #17 --- drizzle/0003_overjoyed_shooting_star.sql | 17 + drizzle/meta/0003_snapshot.json | 475 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package-lock.json | 115 ++++- package.json | 2 + src/app.d.ts | 3 +- src/db/index.ts | 2 +- src/db/schema.ts | 13 +- src/db/survey.ts | 190 +++++-- src/db/users.ts | 11 +- src/hooks.server.ts | 3 +- src/lib/components/Autocomplete.svelte | 93 ++++ src/lib/components/Button.svelte | 23 + src/lib/components/InlineWarning.svelte | 12 + src/lib/components/UserSelector.svelte | 73 +++ src/lib/components/icons/ShareIcon.svelte | 14 + src/lib/components/icons/XMark.svelte | 10 + src/lib/helpers/backend/parse.ts | 13 + src/lib/helpers/backend/permissions.ts | 21 + src/lib/helpers/shared/permissions.ts | 20 + src/lib/{ => helpers/shared}/randomToken.ts | 0 src/lib/session/session.ts | 2 + src/lib/types.ts | 68 ++- src/routes/(app)/+page.server.ts | 25 +- src/routes/(app)/+page.svelte | 14 +- .../(app)/survey/[surveyId]/+page.server.ts | 10 +- .../(app)/survey/[surveyId]/+page.svelte | 103 ++-- .../survey/[surveyId]/edit/+page.server.ts | 14 +- .../(app)/survey/[surveyId]/edit/+page.svelte | 5 +- .../survey/[surveyId]/share/+page.server.ts | 40 ++ .../survey/[surveyId]/share/+page.svelte | 73 +++ src/routes/(app)/survey/new/+page.server.ts | 16 +- src/routes/(app)/users/+server.ts | 19 + src/routes/login/+page.server.ts | 2 +- svelte.config.js | 6 +- 35 files changed, 1383 insertions(+), 131 deletions(-) create mode 100644 drizzle/0003_overjoyed_shooting_star.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/lib/components/Autocomplete.svelte create mode 100644 src/lib/components/Button.svelte create mode 100644 src/lib/components/InlineWarning.svelte create mode 100644 src/lib/components/UserSelector.svelte create mode 100644 src/lib/components/icons/ShareIcon.svelte create mode 100644 src/lib/components/icons/XMark.svelte create mode 100644 src/lib/helpers/backend/parse.ts create mode 100644 src/lib/helpers/backend/permissions.ts create mode 100644 src/lib/helpers/shared/permissions.ts rename src/lib/{ => helpers/shared}/randomToken.ts (100%) create mode 100644 src/routes/(app)/survey/[surveyId]/share/+page.server.ts create mode 100644 src/routes/(app)/survey/[surveyId]/share/+page.svelte create mode 100644 src/routes/(app)/users/+server.ts diff --git a/drizzle/0003_overjoyed_shooting_star.sql b/drizzle/0003_overjoyed_shooting_star.sql new file mode 100644 index 0000000..4bbb51c --- /dev/null +++ b/drizzle/0003_overjoyed_shooting_star.sql @@ -0,0 +1,17 @@ +CREATE TABLE `survey_permissions_table` ( + `id` int AUTO_INCREMENT NOT NULL, + `surveyId` int NOT NULL, + `user` int, + `access` int NOT NULL, + CONSTRAINT `survey_permissions_table_id` PRIMARY KEY(`id`), + CONSTRAINT `survey_user_un` UNIQUE(`surveyId`,`user`) +); +--> statement-breakpoint +ALTER TABLE `survey_permissions_table` ADD CONSTRAINT `survey_permissions_table_surveyId_surveys_table_id_fk` FOREIGN KEY (`surveyId`) REFERENCES `surveys_table`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `survey_permissions_table` ADD CONSTRAINT `survey_permissions_table_user_users_table_id_fk` FOREIGN KEY (`user`) REFERENCES `users_table`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `user_index` ON `survey_permissions_table` (`user`);--> statement-breakpoint +CREATE INDEX `survey_index` ON `survey_permissions_table` (`surveyId`); + + +-- Create the owner permission for each existing survey +INSERT INTO survey_permissions_table (surveyId, user, access) SELECT id,owner,255 FROM surveys_table; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..3b59c24 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,475 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "4d29fb1a-427c-4082-a176-08688a6d9f2e", + "prevId": "43f0adbb-ed0a-4d90-bb8a-1629fa070717", + "tables": { + "sessions": { + "name": "sessions", + "columns": { + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_userId_users_table_id_fk": { + "name": "sessions_userId_users_table_id_fk", + "tableFrom": "sessions", + "tableTo": "users_table", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_token": { + "name": "sessions_token", + "columns": [ + "token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "survey_access_table": { + "name": "survey_access_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "surveyId": { + "name": "surveyId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recepientEmail": { + "name": "recepientEmail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "token_index": { + "name": "token_index", + "columns": [ + "accessToken" + ], + "isUnique": false + } + }, + "foreignKeys": { + "survey_access_table_surveyId_surveys_table_id_fk": { + "name": "survey_access_table_surveyId_surveys_table_id_fk", + "tableFrom": "survey_access_table", + "tableTo": "surveys_table", + "columnsFrom": [ + "surveyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "survey_access_table_id": { + "name": "survey_access_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "survey_answers_table": { + "name": "survey_answers_table", + "columns": { + "participantId": { + "name": "participantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skillId": { + "name": "skillId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "survey_answers_table_participantId_survey_access_table_id_fk": { + "name": "survey_answers_table_participantId_survey_access_table_id_fk", + "tableFrom": "survey_answers_table", + "tableTo": "survey_access_table", + "columnsFrom": [ + "participantId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "survey_answers_table_skillId_survey_skills_table_id_fk": { + "name": "survey_answers_table_skillId_survey_skills_table_id_fk", + "tableFrom": "survey_answers_table", + "tableTo": "survey_skills_table", + "columnsFrom": [ + "skillId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "survey_answers_table_participantId_skillId_pk": { + "name": "survey_answers_table_participantId_skillId_pk", + "columns": [ + "participantId", + "skillId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "survey_permissions_table": { + "name": "survey_permissions_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "surveyId": { + "name": "surveyId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access": { + "name": "access", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_index": { + "name": "user_index", + "columns": [ + "user" + ], + "isUnique": false + }, + "survey_index": { + "name": "survey_index", + "columns": [ + "surveyId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "survey_permissions_table_surveyId_surveys_table_id_fk": { + "name": "survey_permissions_table_surveyId_surveys_table_id_fk", + "tableFrom": "survey_permissions_table", + "tableTo": "surveys_table", + "columnsFrom": [ + "surveyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "survey_permissions_table_user_users_table_id_fk": { + "name": "survey_permissions_table_user_users_table_id_fk", + "tableFrom": "survey_permissions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "survey_permissions_table_id": { + "name": "survey_permissions_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "survey_user_un": { + "name": "survey_user_un", + "columns": [ + "surveyId", + "user" + ] + } + }, + "checkConstraint": {} + }, + "survey_skills_table": { + "name": "survey_skills_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "surveyId": { + "name": "surveyId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "survey_skills_table_surveyId_surveys_table_id_fk": { + "name": "survey_skills_table_surveyId_surveys_table_id_fk", + "tableFrom": "survey_skills_table", + "tableTo": "surveys_table", + "columnsFrom": [ + "surveyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "survey_skills_table_id": { + "name": "survey_skills_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "surveys_table": { + "name": "surveys_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "surveys_table_owner_users_table_id_fk": { + "name": "surveys_table_owner_users_table_id_fk", + "tableFrom": "surveys_table", + "tableTo": "users_table", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "surveys_table_id": { + "name": "surveys_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verification_code": { + "name": "verification_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifcationCodeExpires": { + "name": "verifcationCodeExpires", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_table_id": { + "name": "users_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_table_email_unique": { + "name": "users_table_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a265166..c9fb824 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1734972757800, "tag": "0002_sweet_mentallo", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1736628358111, + "tag": "0003_overjoyed_shooting_star", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b5d90cc..4c8ebee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "nodemailer": "^6.9.16" }, "devDependencies": { + "@melt-ui/pp": "^0.3.2", + "@melt-ui/svelte": "^0.86.2", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-node": "^5.2.9", "@sveltejs/kit": "^2.0.0", @@ -1175,6 +1177,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@internationalized/date": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz", + "integrity": "sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1439,6 +1451,68 @@ "win32" ] }, + "node_modules/@melt-ui/pp": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@melt-ui/pp/-/pp-0.3.2.tgz", + "integrity": "sha512-xKkPvaIAFinklLXcQOpwZ8YSpqAFxykjWf8Y/fSJQwsixV/0rcFs07hJ49hJjPy5vItvw5Qa0uOjzFUbXzBypQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^3.0.3", + "magic-string": "^0.30.5" + }, + "peerDependencies": { + "@melt-ui/svelte": ">= 0.29.0", + "svelte": "^3.55.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, + "node_modules/@melt-ui/pp/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@melt-ui/svelte": { + "version": "0.86.2", + "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.86.2.tgz", + "integrity": "sha512-wRVN603oIt1aXvx2QRmKqVDJgTScSvr/WJLLokkD8c4QzHgn6pfpPtUKmhV6Dvkk+OY89OG/1Irkd6ouA50Ztw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.3.1", + "@floating-ui/dom": "^1.4.5", + "@internationalized/date": "^3.5.0", + "dequal": "^2.0.3", + "focus-trap": "^7.5.2", + "nanoid": "^5.0.4" + }, + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118" + } + }, + "node_modules/@melt-ui/svelte/node_modules/nanoid": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.6.tgz", @@ -2253,6 +2327,16 @@ "vite": "^5.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/forms": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz", @@ -3478,6 +3562,16 @@ "node": ">=0.10" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", @@ -4181,6 +4275,16 @@ "dev": true, "license": "ISC" }, + "node_modules/focus-trap": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz", + "integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.2.0" + } + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -6279,6 +6383,13 @@ "@types/estree": "^1.0.6" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -6562,8 +6673,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "devOptional": true, + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index 2c92d3a..1058af0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "lint": "prettier --check . && eslint ." }, "devDependencies": { + "@melt-ui/pp": "^0.3.2", + "@melt-ui/svelte": "^0.86.2", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-node": "^5.2.9", "@sveltejs/kit": "^2.0.0", diff --git a/src/app.d.ts b/src/app.d.ts index b0f4e52..287fe3a 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,5 +1,6 @@ // See https://svelte.dev/docs/kit/types#app.d.ts +import type { UserId } from "$lib/types"; import { Session } from "./lib/session/session"; // for information about these interfaces @@ -8,7 +9,7 @@ declare global { // interface Error {} interface Locals { session: Session | null, - userId: number | null + userId: UserId | null } // interface PageData {} // interface PageState {} diff --git a/src/db/index.ts b/src/db/index.ts index 84361cb..2294d3b 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -6,5 +6,5 @@ export const db = drizzle( env.DATABASE_URL ?? '', { schema, - mode: 'default' + mode: 'default', }); \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index fbff521..07b0d30 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { index, int, primaryKey, mysqlTable, text, varchar, bigint, date } from "drizzle-orm/mysql-core"; +import { index, int, primaryKey, mysqlTable, text, varchar, bigint, date, unique } from "drizzle-orm/mysql-core"; export const usersTable = mysqlTable("users_table", { id: int().autoincrement().primaryKey(), @@ -44,3 +44,14 @@ export const surveyAnswersTable = mysqlTable("survey_answers_table", { }, (table) => ({ pk: primaryKey({ columns: [table.participantId, table.skillId] }), })); + +export const surveyPermissionsTable = mysqlTable("survey_permissions_table", { + id: int().autoincrement().primaryKey(), + surveyId: int().notNull().references(() => surveysTable.id, { onDelete: "cascade" }), + user: int().references(() => usersTable.id, { onDelete: "cascade" }), // NULL means "any user" + access: int().notNull(), +}, (table) => ({ + userIndex: index("user_index").on(table.user), + surveyIndex: index("survey_index").on(table.surveyId), + survey_user_un: unique("survey_user_un").on(table.surveyId, table.user), +})); diff --git a/src/db/survey.ts b/src/db/survey.ts index 0c88626..424f070 100644 --- a/src/db/survey.ts +++ b/src/db/survey.ts @@ -3,55 +3,144 @@ import debug from "debug"; const log = debug('survey:admin:edit'); -import { eq, and, inArray } from "drizzle-orm"; +import { eq, and, inArray, gte, sql } from "drizzle-orm"; import { db } from "../db"; -import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from "../db/schema"; -import { generateRandomToken } from "$lib/randomToken"; +import { surveyAccessTable, surveyAnswersTable, surveyPermissionsTable, surveySkillsTable, surveysTable, usersTable } from "../db/schema"; +import { AccessLevel, type AccessToken, type Email, type ParticipantId, type SkillId, type SurveyData, type SurveyId, type SurveyMetaData, type UserId } from "$lib/types"; +import { checkAccess, whenAccess } from "$lib/helpers/shared/permissions"; +import { generateRandomToken } from "$lib/helpers/shared/randomToken"; -export async function loadSurvey(id: number, ownerId: number) { - const survey = await db.select().from(surveysTable).where(and(eq(surveysTable.id, id), eq(surveysTable.owner, ownerId))).limit(1); - const participants = await db.select().from(surveyAccessTable).where(eq(surveyAccessTable.surveyId, id)); - const skills = await db.select().from(surveySkillsTable).where(eq(surveySkillsTable.surveyId, id)); - const answers = await db.select().from(surveyAnswersTable).where(inArray(surveyAnswersTable.participantId, participants.map(participant => participant.id))); +/** + * Load the meta data for a single survey. + * @param surveyId The survey ID to load + * @param userId The ID of the user to load the survey for. This ensures proper access control + * @returns The survey meta data or null if no such survey exists or the user doesn't have access + */ +export async function loadSurveyMetadata(surveyId: SurveyId, userId: UserId): Promise { + const surveys = await db.select() + .from(surveyPermissionsTable) + .innerJoin(surveysTable, eq(surveysTable.id, surveyPermissionsTable.surveyId)) + .where(and(eq(surveysTable.id, surveyId), eq(surveyPermissionsTable.user, userId))).limit(1); - if (survey.length === 0) { + if (surveys.length === 0) { return null; } return { - id, - title: survey[0].title, - description: survey[0].description, - participants: participants.map(participant => ({ - id: participant.id, - email: participant.recepientEmail, - accessToken: participant.accessToken, - answers: answers.filter(answer => answer.participantId === participant.id).map(answer => ({ - skillId: answer.skillId, - rating: answer.rating / 10 // convert back to original vallue. The DB stores integer values only to prevent rounding and matching errors - })) - })), - skills: skills.map(skill => ({ - id: skill.id, - title: skill.title, - description: skill.description - })) + id: surveys[0].surveys_table.id as SurveyId, + title: surveys[0].surveys_table.title, + description: surveys[0].surveys_table.description, + permissions: surveys[0].survey_permissions_table.access as AccessLevel, } } -export async function loadSurveyData(surveyId: string, userId: number | null) { - const sId = parseInt(surveyId); +/** + * Load all permissions for a specific survey. This is different from the permissions field in a specific + * survey for a specific user, which gives the permissions for _that specific user_. This call returns all + * permissions for the survey and should only be available to owners. + * + * @param surveyId The ID of the survey to load the permissions for + */ +export async function loadSurveyPermissions(surveyId: SurveyId) { + let permissions = await db.select().from(surveyPermissionsTable).leftJoin(usersTable, eq(surveyPermissionsTable.user, usersTable.id)).where(eq(surveyPermissionsTable.surveyId, surveyId)); + return permissions.map(p => ({ + user: p.users_table ? { + id: p.users_table.id as UserId, + email: p.users_table.email as Email + } : null, + access: p.survey_permissions_table.access as AccessLevel + })); +} - if (isNaN(sId)) { - log('Invalid survey ID %s', surveyId); - error(404, 'Invalid survey ID'); +export async function loadMySurveys(userId: UserId): Promise { + /// Get all surveys I have access to + const mySurves = await db.select() + .from(surveyPermissionsTable) + .innerJoin(surveysTable, eq(surveysTable.id, surveyPermissionsTable.surveyId)) + .where(eq(surveyPermissionsTable.user, userId)); + + return mySurves.map(survey => ({ + id: survey.surveys_table.id as SurveyId, + title: survey.surveys_table.title, + description: survey.surveys_table.description, + permissions: survey.survey_permissions_table.access, + })) +} + +/** + * Load the fill rates for the given surveys. + * + * Note: This will ignore surveys in the input where the user doesn't have the appropriate access to see the fill rate. + * @param surveys The surveys to load the fill rates for + * @returns The surveys as before, but will the fill rates field filled, where appropriate. + */ +export async function loadSurveyFillRates(surveys: SurveyMetaData[]): Promise { + const visibleSurveys = surveys.filter(survey => checkAccess(survey, AccessLevel.ReadResult)); // only ReadResult or above access allows users to see the fill rate + const fillRates = await db.select({ + surveyId: surveyAccessTable.surveyId, + filled: sql`count(distinct(${surveyAnswersTable.participantId}))`.mapWith(Number), + expected: sql`count(distinct(${surveyAccessTable.id}))`.mapWith(Number), + }) + .from(surveyAccessTable) + .leftJoin(surveyAnswersTable, eq(surveyAccessTable.id, surveyAnswersTable.participantId)) + .groupBy(surveyAccessTable.surveyId) + .where(inArray(surveyAccessTable.surveyId, visibleSurveys.map(survey => survey.id))); + + return surveys.map(survey => ({ + ...survey, + fillRate: fillRates.find(fillRate => fillRate.surveyId === survey.id) + }) + ) +} + +/** + * Load a survey in the context of a specific user + * @param id The ID of the survey in questions + * @param userId The ID of the user for which to load the survey. This will ensure correct access to the survey. + * @returns The data of the survey or null, if no such survey could be found (or the user doesn't have access to it) + */ +export async function loadSurvey(id: SurveyId, userId: UserId): Promise { + const survey = await loadSurveyMetadata(id, userId); + + if (!survey) { + return null; } + + const participants = await whenAccess(survey, AccessLevel.Clone, () => db.select().from(surveyAccessTable).where(eq(surveyAccessTable.surveyId, id))); + const skills = await whenAccess(survey, AccessLevel.Clone, () => db.select().from(surveySkillsTable).where(eq(surveySkillsTable.surveyId, id))); + const answers = await whenAccess(survey, AccessLevel.ReadResult, () => { + if (participants) { + return db.select().from(surveyAnswersTable).where(inArray(surveyAnswersTable.participantId, participants.map(participant => participant.id))) + } + return null; + }); + + return { + ...survey, + participants: participants?.map(participant => ({ + id: participant.id as ParticipantId, + email: participant.recepientEmail as Email, + accessToken: whenAccess(survey, AccessLevel.Edit, () => participant.accessToken as AccessToken), + answers: answers?.filter(answer => answer.participantId === participant.id).map(answer => ({ + skillId: answer.skillId as SkillId, + rating: answer.rating / 10 // convert back to original vallue. The DB stores integer values only to prevent rounding and matching errors + })) ?? [] + })) ?? [], + skills: skills?.map(skill => ({ + id: skill.id as SkillId, + title: skill.title, + description: skill.description + })) ?? [] + } +} + +export async function loadSurveyData(surveyId: SurveyId, userId: UserId | null) { if (!userId) { log('User is not logged in'); error(403, 'User is not logged in'); } - const surveyData = await loadSurvey(sId, userId); + const surveyData = await loadSurvey(surveyId, userId); if (!surveyData) { log('Survey not found or user (%s) does not have access: %s', userId, surveyId); error(404, 'Survey not found'); @@ -60,11 +149,44 @@ export async function loadSurveyData(surveyId: string, userId: number | null) { } // add a new participant to a survey -export async function addParticipant(surveyId: number, recepientEmail: string) { +export async function addParticipant(surveyId: SurveyId, recepientEmail: string) { await db.insert(surveyAccessTable).values({ surveyId, recepientEmail, accessToken: generateRandomToken() }); } // add a new skill to a survey export async function addSkill(surveyId: number, title: string, description: string) { await db.insert(surveySkillsTable).values({ surveyId, title, description }); -} \ No newline at end of file +} + +/// Check whether a given user has at least the given access level to the given survey. This is based on a database query and doesn't need the survey object already to be loaded +export async function hasAccess(surveyId: number, userId: number, accessLevel: AccessLevel): Promise { + const result = await db.select().from(surveyPermissionsTable).where(and(eq(surveyPermissionsTable.surveyId, surveyId), eq(surveyPermissionsTable.user, userId), gte(surveyPermissionsTable.access, accessLevel))); + return result.length > 0; +} + + +/// A single entry in the permissions list +export type PermissionEntry = { + user: UserId | null, + access: AccessLevel +} + +/// Atomically set the permissions for a given survey. This will clear the existing permissions and set the news ones in one transactions to prevent race conditions that could leave users without access to their survey +export async function setPermissions(surveyId: SurveyId, permissions: PermissionEntry[]) { + const remainingUserIds = permissions.map(permission => permission.user); + await db.transaction(async (tx) => { + await tx.delete(surveyPermissionsTable).where(eq(surveyPermissionsTable.surveyId, surveyId)); + for (const permission of permissions) { + await tx.insert(surveyPermissionsTable).values({ surveyId, user: permission.user, access: permission.access }).onDuplicateKeyUpdate({ + set: { access: permission.access } + }); + } + }); +} + +/// Grant access to to a given survey for a given user. If the user already has access, the access level will be updated +export async function grantAccess(surveyId: SurveyId, userId: UserId | null, accessLevel: AccessLevel) { + await db.insert(surveyPermissionsTable).values({ surveyId, user: userId, access: accessLevel }).onDuplicateKeyUpdate({ + set: { access: accessLevel } + }); +} diff --git a/src/db/users.ts b/src/db/users.ts index e493d3b..5bee587 100644 --- a/src/db/users.ts +++ b/src/db/users.ts @@ -1,20 +1,15 @@ import { config } from "$lib/configuration"; -import { generateRandomToken } from "$lib/randomToken"; -import type { Email, Password, VerificationCode } from "$lib/types"; +import type { Email, Password, User, UserId, VerificationCode } from "$lib/types"; import { hash, verify } from "@node-rs/argon2"; import { db } from "."; import { usersTable } from "./schema"; import { eq } from "drizzle-orm"; import { err, ok, type Result } from "$lib/result"; import debug from "debug"; +import { generateRandomToken } from "$lib/helpers/shared/randomToken"; const log = debug('db:users'); -export type User = { - id: number; - email: Email; -} - export async function createNewUser(email: Email, password: Password): Promise<{ verificationCode: VerificationCode | undefined }> { const hashedPassword = await hash(password); @@ -71,7 +66,7 @@ export async function verifyUser(verificationCode: VerificationCode): Promise { } event.locals.session = session; - event.locals.userId = session?.userId ?? null; + event.locals.userId = session?.userId as UserId ?? null; return resolve(event); }; diff --git a/src/lib/components/Autocomplete.svelte b/src/lib/components/Autocomplete.svelte new file mode 100644 index 0000000..6b8cf77 --- /dev/null +++ b/src/lib/components/Autocomplete.svelte @@ -0,0 +1,93 @@ + + +
+ + {#if error} +

{error}

+ {/if} +
+{#if open} +
    + {#await options} +

    Loading...

    + {:then options} + {#each options ?? [] as entry} +
  • + {itemToString?.(entry) ?? String(entry)} +
  • + {/each} + {/await} +
+{/if} diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte new file mode 100644 index 0000000..3de5d61 --- /dev/null +++ b/src/lib/components/Button.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/InlineWarning.svelte b/src/lib/components/InlineWarning.svelte new file mode 100644 index 0000000..a1d546e --- /dev/null +++ b/src/lib/components/InlineWarning.svelte @@ -0,0 +1,12 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/UserSelector.svelte b/src/lib/components/UserSelector.svelte new file mode 100644 index 0000000..911af52 --- /dev/null +++ b/src/lib/components/UserSelector.svelte @@ -0,0 +1,73 @@ + + +{#if user} +
+ user.email} + bind:selectedItem={user} + {error} + required + /> + +
+ +{:else} +
+
+ Anyone + +
+ +
+{/if} diff --git a/src/lib/components/icons/ShareIcon.svelte b/src/lib/components/icons/ShareIcon.svelte new file mode 100644 index 0000000..f498ec8 --- /dev/null +++ b/src/lib/components/icons/ShareIcon.svelte @@ -0,0 +1,14 @@ + + + diff --git a/src/lib/components/icons/XMark.svelte b/src/lib/components/icons/XMark.svelte new file mode 100644 index 0000000..c3a4b4d --- /dev/null +++ b/src/lib/components/icons/XMark.svelte @@ -0,0 +1,10 @@ + + + diff --git a/src/lib/helpers/backend/parse.ts b/src/lib/helpers/backend/parse.ts new file mode 100644 index 0000000..ccf617a --- /dev/null +++ b/src/lib/helpers/backend/parse.ts @@ -0,0 +1,13 @@ +import { error } from "@sveltejs/kit"; +import debug from "debug"; + +const log = debug('types'); + +export function parse(value: string): T { + const parsed = parseInt(value); + if (isNaN(parsed)) { + log('Invalid ID %s', value); + error(400, 'Invalid ID'); + } + return parsed as T; +} \ No newline at end of file diff --git a/src/lib/helpers/backend/permissions.ts b/src/lib/helpers/backend/permissions.ts new file mode 100644 index 0000000..e31d353 --- /dev/null +++ b/src/lib/helpers/backend/permissions.ts @@ -0,0 +1,21 @@ +import type { AccessLevel, SurveyId, UserId } from "$lib/types"; +import { error } from "@sveltejs/kit"; +import { hasAccess } from "../../../db/survey"; + +/** + * Check whether the given user has the required access level to the given survey. This will immediately return the appropriate HTTP error if the user doesn't have access + * + * @param surveyId The ID of the survey to check + * @param userId The ID of the user to check the access for + * @param accessLevel The required access level the user would need + */ +export async function ensureAccess(surveyId: SurveyId, userId: UserId | undefined | null, accessLevel: AccessLevel, errorMsg?: string): Promise { + if (!userId) { + error(401, 'User is not logged in'); + } + const userHasAccess = await hasAccess(surveyId, userId, accessLevel); + if (!userHasAccess) { + error(403, errorMsg ?? 'User does not have the required permission'); + } + return userId +} \ No newline at end of file diff --git a/src/lib/helpers/shared/permissions.ts b/src/lib/helpers/shared/permissions.ts new file mode 100644 index 0000000..7075e61 --- /dev/null +++ b/src/lib/helpers/shared/permissions.ts @@ -0,0 +1,20 @@ + +import type { AccessLevel, SurveyMetaData } from "$lib/types"; + +/** + * Check whether a given survey has at least the required access level. + * + * @param survey The survey to check + * @param accessLevel The minimum access level required + */ +export function checkAccess(survey: SurveyMetaData, accessLevel: AccessLevel) { + return survey.permissions >= accessLevel; // Note: this simple check doesn't strictly need a function, but this makes it easier to read wherever we employ that condition +} + +/// Execute the given action only if the user has the required access to the given survey +export function whenAccess(survey: SurveyMetaData, accessLevel: AccessLevel, action: () => T): T | null { + if (checkAccess(survey, accessLevel)) { + return action(); + } + return null; +} \ No newline at end of file diff --git a/src/lib/randomToken.ts b/src/lib/helpers/shared/randomToken.ts similarity index 100% rename from src/lib/randomToken.ts rename to src/lib/helpers/shared/randomToken.ts diff --git a/src/lib/session/session.ts b/src/lib/session/session.ts index a252778..63429a3 100644 --- a/src/lib/session/session.ts +++ b/src/lib/session/session.ts @@ -2,6 +2,7 @@ import { db } from '../../db'; import { sessions } from '../../db/schema'; import { eq, lt } from 'drizzle-orm'; import type { RequestEvent } from '@sveltejs/kit'; +import type { UserId } from '$lib/types'; export type Session = { userId: number; @@ -31,6 +32,7 @@ export async function validateSession(token: string) { db.update(sessions).set({ expires: newExpires }).where(eq(sessions.token, token)); // refresh the session as long as the user is working in it return { ...session[0], + userId: session[0].userId as UserId, expires: newExpires }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index a3f0767..49f3149 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2,4 +2,70 @@ import type { Branded } from "./branded"; export type Email = Branded; export type Password = Branded; -export type VerificationCode = Branded; \ No newline at end of file +export type AccessToken = Branded; +export type VerificationCode = Branded; +export type SurveyId = Branded; +export type UserId = Branded; +export type ParticipantId = Branded; +export type SkillId = Branded; + + +/// Access level to a survey. Higher levels include lower levels +export enum AccessLevel { + /// Users may clone the survey + Clone = 10, + /// Users may read the results of a survey + ReadResult = 20, + /// Users may edit the survey + Edit = 30, + /// Users own this survey and may do whatever they like + Owner = 255, +} + +/// shared user datatype +export type User = { + id: UserId; + email: Email; +} + +/// marker value for "any user" used in the form input fields +export const AnyoneMarker = '--(Anyone)--'; + +/// meta data for a specific survey +export type SurveyMetaData = { + id: SurveyId; + title: string; + description: string | null; + fillRate?: { + filled: number; + expected: number; + }; + /// the permission of the specific user in whose context the survey is loaded. This will not include other user's permissions + permissions: AccessLevel; +} + +export type SurveyAnswer = { + skillId: SkillId; + rating: number; +} + +/// Information about a specific participant in a survey +export type SurveyParticipant = { + id: ParticipantId; + email: Email; + accessToken: AccessToken | null; + answers: SurveyAnswer[]; +} + +/// Information about a specific skill +export type SurveySkill = { + id: SkillId; + title: string; + description: string | undefined | null; +} + +/// Complete data for a survey including skills & participants +export type SurveyData = SurveyMetaData & { + participants: SurveyParticipant[]; + skills: SurveySkill[]; +} \ No newline at end of file diff --git a/src/routes/(app)/+page.server.ts b/src/routes/(app)/+page.server.ts index 9744219..e257c4d 100644 --- a/src/routes/(app)/+page.server.ts +++ b/src/routes/(app)/+page.server.ts @@ -1,33 +1,16 @@ import { error } from '@sveltejs/kit'; -import { db } from '../../db'; -import { surveyAccessTable, surveyAnswersTable, surveysTable } from '../../db/schema'; import type { PageServerLoad } from './$types'; -import { eq, inArray, not, sql } from 'drizzle-orm'; +import { loadMySurveys, loadSurveyFillRates } from '../../db/survey'; export const load: PageServerLoad = async ({ locals }) => { if (!locals.userId) { error(403, 'User is not logged in'); } - const mySurveys = await db.select().from(surveysTable).where(eq(surveysTable.owner, locals.userId)); - const fillRates = await db.select({ - surveyId: surveyAccessTable.surveyId, - filled: sql`count(distinct(${surveyAnswersTable.participantId}))`.mapWith(Number), - expected: sql`count(distinct(${surveyAccessTable.id}))`.mapWith(Number), - }) - .from(surveyAccessTable) - .leftJoin(surveyAnswersTable, eq(surveyAccessTable.id, surveyAnswersTable.participantId)) - .groupBy(surveyAccessTable.surveyId); + const mySurveys = await loadMySurveys(locals.userId); + const surveyWithFillRates = await loadSurveyFillRates(mySurveys); return { - surveys: mySurveys.map(survey => ({ - id: survey.id, - title: survey.title, - description: survey.description, - fillRate: { - filled: fillRates.find(fillRate => fillRate.surveyId === survey.id)?.filled ?? 0, - expected: fillRates.find(fillRate => fillRate.surveyId === survey.id)?.expected ?? 0 - } - })) + surveys: surveyWithFillRates } } \ No newline at end of file diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 5a30e66..122c7c4 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -38,12 +38,14 @@
  • {survey.title} - - ({survey.fillRate.filled}/{survey.fillRate.expected}) - {#if survey.fillRate.filled === survey.fillRate.expected && survey.fillRate.expected > 0} - - {/if} - + {#if survey.fillRate} + + ({survey.fillRate.filled}/{survey.fillRate.expected}) + {#if survey.fillRate.filled === survey.fillRate.expected && survey.fillRate.expected > 0} + + {/if} + + {/if}
    { - return await loadSurveyData(params.surveyId, locals.userId); + await ensureAccess(parse(params.surveyId), locals.userId, AccessLevel.Clone, 'The current user cannot view this survey'); + return await loadSurveyData(parse(params.surveyId), locals.userId); } export const actions = { deleteAnswers: async ({ params, locals, request }) => { - const survey = await loadSurveyData(params.surveyId, locals.userId); + await ensureAccess(parse(params.surveyId), locals.userId, AccessLevel.Edit, 'The current user cannot edit this survey'); + + const survey = await loadSurveyData(parse(params.surveyId), locals.userId); let formData = await request.formData(); const participantId = parseInt(formData.get('participantId')?.toString() ?? ''); diff --git a/src/routes/(app)/survey/[surveyId]/+page.svelte b/src/routes/(app)/survey/[surveyId]/+page.svelte index ae024e0..32e8383 100644 --- a/src/routes/(app)/survey/[surveyId]/+page.svelte +++ b/src/routes/(app)/survey/[surveyId]/+page.svelte @@ -14,6 +14,9 @@ import DeleteIcon from '$lib/components/icons/DeleteIcon.svelte'; import WarningDialog from '$lib/components/WarningDialog.svelte'; import EditIcon from '$lib/components/icons/EditIcon.svelte'; + import ShareIcon from '$lib/components/icons/ShareIcon.svelte'; + import { checkAccess } from '$lib/helpers/shared/permissions'; + import { AccessLevel, type ParticipantId } from '$lib/types'; let { data }: { data: PageData } = $props(); @@ -21,14 +24,14 @@ if (data.participants.length > 0) { return data.participants.map((participant) => ({ skill: skill.title, - participant: participant.id, + participant: participant.id as ParticipantId, rating: participant.answers.find((answer) => answer.skillId === skill.id)?.rating })); } else { // fallback pseudo participant because empty diagram data will break the diagram and make the survey page unaccessible return { skill: skill.title, - participant: -1, + participant: -1 as ParticipantId, rating: undefined }; } @@ -43,6 +46,8 @@ let participantAnswersDeletionCandidateId = $state(null); + $inspect(data); + async function deleteSurvey() { await fetch('', { method: 'DELETE' @@ -56,29 +61,43 @@ - - - - - + {#if checkAccess(data, AccessLevel.Clone)} + + + {/if} + {#if checkAccess(data, AccessLevel.Edit)} + + + {/if} + {#if checkAccess(data, AccessLevel.Owner)} + + + + + {/if}
    {#if data.description} @@ -95,17 +114,19 @@ {/if} - + {#if participant.accessToken} + + {/if} {#if participant.answers.length > 0} + + {/each} + + + + diff --git a/src/routes/(app)/survey/new/+page.server.ts b/src/routes/(app)/survey/new/+page.server.ts index 4bc2383..ec560c2 100644 --- a/src/routes/(app)/survey/new/+page.server.ts +++ b/src/routes/(app)/survey/new/+page.server.ts @@ -1,14 +1,18 @@ import type { Actions, PageServerLoad } from './$types'; import { error, redirect } from '@sveltejs/kit'; -import { surveysTable } from '../../../../db/schema'; +import { surveyPermissionsTable, surveysTable } from '../../../../db/schema'; import { db } from '../../../../db'; import { fromFormData } from '$lib/survey'; import { addParticipant, addSkill, loadSurvey } from '../../../../db/survey'; +import { AccessLevel, type SurveyId } from '$lib/types'; +import { ensureAccess } from '$lib/helpers/backend/permissions'; +import { parse } from '$lib/helpers/backend/parse'; export const load: PageServerLoad = async ({ url, locals }) => { const baseSurveyId = url.searchParams.get('from'); - if (baseSurveyId) { - const baseSurvey = await loadSurvey(parseInt(baseSurveyId), locals.userId ?? 0); + if (baseSurveyId && locals.userId) { + await ensureAccess(parse(baseSurveyId), locals.userId, AccessLevel.Clone, 'The current user cannot clone this survey'); + const baseSurvey = await loadSurvey(parse(baseSurveyId), locals.userId); return baseSurvey; } return null; @@ -35,9 +39,13 @@ export const actions = { error(400, 'At least one skill is required'); } + // TODO those should probably be handled in a single transaction const ids = await db.insert(surveysTable).values({ title, description, owner }).$returningId(); - const surveyId = ids[0].id; + const surveyId = ids[0].id as SurveyId; + // insert the owner permission for this survey + db.insert(surveyPermissionsTable).values({ surveyId, user: owner, access: AccessLevel.Owner }); + // record all participants & skills for (const participant of participants) { await addParticipant(surveyId, participant); } diff --git a/src/routes/(app)/users/+server.ts b/src/routes/(app)/users/+server.ts new file mode 100644 index 0000000..51682ff --- /dev/null +++ b/src/routes/(app)/users/+server.ts @@ -0,0 +1,19 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import { db } from "../../../db"; +import { usersTable } from "../../../db/schema"; +import { like } from "drizzle-orm"; + +export const GET: RequestHandler = async ({ url, locals }) => { + if (!locals.userId) { + return new Response(null, { status: 403 }); + } + + const searchTerm = url.searchParams.get('query'); + if ((searchTerm?.length ?? 0) < 3) { + return new Response(JSON.stringify({ error: 'Query must be at least 3 characters long' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + } + + const users = await db.select({ id: usersTable.id, email: usersTable.email }).from(usersTable).where(like(usersTable.email, `%${searchTerm}%`)); + + return json(users); +}; \ No newline at end of file diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 233708f..523b9e5 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -6,10 +6,10 @@ import { usersTable } from '../../db/schema'; import { eq } from 'drizzle-orm'; import { error } from '@sveltejs/kit'; import { verify } from '@node-rs/argon2'; -import { generateRandomToken } from '$lib/randomToken'; import debug from 'debug'; import { config } from '$lib/configuration'; +import { generateRandomToken } from '$lib/helpers/shared/randomToken'; let log = debug('login'); diff --git a/svelte.config.js b/svelte.config.js index e0a641e..e23efb5 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,11 +1,15 @@ import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +import { preprocessMeltUI, sequence } from '@melt-ui/pp' /** @type {import('@sveltejs/kit').Config} */ const config = { // Consult https://svelte.dev/docs/kit/integrations // for more information about preprocessors - preprocess: vitePreprocess(), + preprocess: sequence([ + vitePreprocess(), + preprocessMeltUI() // add to the end! + ]), kit: { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.