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
This commit is contained in:
Markus Brueckner 2025-01-09 21:59:58 +01:00
parent 97e84aaf09
commit 309dc1c7df
35 changed files with 1383 additions and 131 deletions

View file

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

View file

@ -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": {}
}
}

View file

@ -22,6 +22,13 @@
"when": 1734972757800, "when": 1734972757800,
"tag": "0002_sweet_mentallo", "tag": "0002_sweet_mentallo",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1736628358111,
"tag": "0003_overjoyed_shooting_star",
"breakpoints": true
} }
] ]
} }

115
package-lock.json generated
View file

@ -26,6 +26,8 @@
"nodemailer": "^6.9.16" "nodemailer": "^6.9.16"
}, },
"devDependencies": { "devDependencies": {
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.2",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.2.9", "@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
@ -1175,6 +1177,16 @@
"url": "https://github.com/sponsors/nzakas" "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": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -1439,6 +1451,68 @@
"win32" "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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.6", "version": "0.2.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.6.tgz",
@ -2253,6 +2327,16 @@
"vite": "^5.0.0" "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": { "node_modules/@tailwindcss/forms": {
"version": "0.5.9", "version": "0.5.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz",
@ -3478,6 +3562,16 @@
"node": ">=0.10" "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": { "node_modules/detect-libc": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
@ -4181,6 +4275,16 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/foreground-child": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
@ -6279,6 +6383,13 @@
"@types/estree": "^1.0.6" "@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": { "node_modules/tailwind-merge": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
@ -6562,8 +6673,8 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD", "devOptional": true,
"optional": true "license": "0BSD"
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",

View file

@ -12,6 +12,8 @@
"lint": "prettier --check . && eslint ." "lint": "prettier --check . && eslint ."
}, },
"devDependencies": { "devDependencies": {
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.2",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.2.9", "@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",

3
src/app.d.ts vendored
View file

@ -1,5 +1,6 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
import type { UserId } from "$lib/types";
import { Session } from "./lib/session/session"; import { Session } from "./lib/session/session";
// for information about these interfaces // for information about these interfaces
@ -8,7 +9,7 @@ declare global {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
session: Session | null, session: Session | null,
userId: number | null userId: UserId | null
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}

View file

@ -6,5 +6,5 @@ export const db = drizzle(
env.DATABASE_URL ?? '', env.DATABASE_URL ?? '',
{ {
schema, schema,
mode: 'default' mode: 'default',
}); });

View file

@ -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", { export const usersTable = mysqlTable("users_table", {
id: int().autoincrement().primaryKey(), id: int().autoincrement().primaryKey(),
@ -44,3 +44,14 @@ export const surveyAnswersTable = mysqlTable("survey_answers_table", {
}, (table) => ({ }, (table) => ({
pk: primaryKey({ columns: [table.participantId, table.skillId] }), 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),
}));

View file

@ -3,55 +3,144 @@ import debug from "debug";
const log = debug('survey:admin:edit'); 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 { db } from "../db";
import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from "../db/schema"; import { surveyAccessTable, surveyAnswersTable, surveyPermissionsTable, surveySkillsTable, surveysTable, usersTable } from "../db/schema";
import { generateRandomToken } from "$lib/randomToken"; 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); * Load the meta data for a single survey.
const participants = await db.select().from(surveyAccessTable).where(eq(surveyAccessTable.surveyId, id)); * @param surveyId The survey ID to load
const skills = await db.select().from(surveySkillsTable).where(eq(surveySkillsTable.surveyId, id)); * @param userId The ID of the user to load the survey for. This ensures proper access control
const answers = await db.select().from(surveyAnswersTable).where(inArray(surveyAnswersTable.participantId, participants.map(participant => participant.id))); * @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<SurveyMetaData | null> {
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 null;
} }
return { return {
id, id: surveys[0].surveys_table.id as SurveyId,
title: survey[0].title, title: surveys[0].surveys_table.title,
description: survey[0].description, description: surveys[0].surveys_table.description,
participants: participants.map(participant => ({ permissions: surveys[0].survey_permissions_table.access as AccessLevel,
id: participant.id, }
email: participant.recepientEmail, }
accessToken: participant.accessToken,
answers: answers.filter(answer => answer.participantId === participant.id).map(answer => ({ /**
skillId: answer.skillId, * Load all permissions for a specific survey. This is different from the permissions field in a specific
rating: answer.rating / 10 // convert back to original vallue. The DB stores integer values only to prevent rounding and matching errors * 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
}));
}
export async function loadMySurveys(userId: UserId): Promise<SurveyMetaData[]> {
/// 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,
})) }))
})), }
skills: skills.map(skill => ({
id: skill.id, /**
* 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<SurveyMetaData[]> {
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<SurveyData | null> {
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, title: skill.title,
description: skill.description description: skill.description
})) })) ?? []
} }
} }
export async function loadSurveyData(surveyId: string, userId: number | null) { export async function loadSurveyData(surveyId: SurveyId, userId: UserId | null) {
const sId = parseInt(surveyId);
if (isNaN(sId)) {
log('Invalid survey ID %s', surveyId);
error(404, 'Invalid survey ID');
}
if (!userId) { if (!userId) {
log('User is not logged in'); log('User is not logged in');
error(403, '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) { if (!surveyData) {
log('Survey not found or user (%s) does not have access: %s', userId, surveyId); log('Survey not found or user (%s) does not have access: %s', userId, surveyId);
error(404, 'Survey not found'); error(404, 'Survey not found');
@ -60,7 +149,7 @@ export async function loadSurveyData(surveyId: string, userId: number | null) {
} }
// add a new participant to a survey // 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() }); await db.insert(surveyAccessTable).values({ surveyId, recepientEmail, accessToken: generateRandomToken() });
} }
@ -68,3 +157,36 @@ export async function addParticipant(surveyId: number, recepientEmail: string) {
export async function addSkill(surveyId: number, title: string, description: string) { export async function addSkill(surveyId: number, title: string, description: string) {
await db.insert(surveySkillsTable).values({ surveyId, title, description }); await db.insert(surveySkillsTable).values({ surveyId, title, description });
} }
/// 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<boolean> {
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 }
});
}

View file

@ -1,20 +1,15 @@
import { config } from "$lib/configuration"; import { config } from "$lib/configuration";
import { generateRandomToken } from "$lib/randomToken"; import type { Email, Password, User, UserId, VerificationCode } from "$lib/types";
import type { Email, Password, VerificationCode } from "$lib/types";
import { hash, verify } from "@node-rs/argon2"; import { hash, verify } from "@node-rs/argon2";
import { db } from "."; import { db } from ".";
import { usersTable } from "./schema"; import { usersTable } from "./schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { err, ok, type Result } from "$lib/result"; import { err, ok, type Result } from "$lib/result";
import debug from "debug"; import debug from "debug";
import { generateRandomToken } from "$lib/helpers/shared/randomToken";
const log = debug('db:users'); const log = debug('db:users');
export type User = {
id: number;
email: Email;
}
export async function createNewUser(email: Email, password: Password): Promise<{ verificationCode: VerificationCode | undefined }> { export async function createNewUser(email: Email, password: Password): Promise<{ verificationCode: VerificationCode | undefined }> {
const hashedPassword = await hash(password); const hashedPassword = await hash(password);
@ -71,7 +66,7 @@ export async function verifyUser(verificationCode: VerificationCode): Promise<Re
await db.update(usersTable).set({ verification_code: null, verifcationCodeExpires: null }).where(eq(usersTable.verification_code, verificationCode)); await db.update(usersTable).set({ verification_code: null, verifcationCodeExpires: null }).where(eq(usersTable.verification_code, verificationCode));
return ok({ return ok({
id: user[0].id, id: user[0].id as UserId,
email: user[0].email as Email email: user[0].email as Email
}); });
} }

View file

@ -3,6 +3,7 @@ import type { Handle } from "@sveltejs/kit";
import { deleteSessionTokenCookie, setSessionTokenCookie, validateSession } from "./lib/session/session"; import { deleteSessionTokenCookie, setSessionTokenCookie, validateSession } from "./lib/session/session";
import debug from 'debug'; import debug from 'debug';
import type { UserId } from "$lib/types";
const log = debug('hooks'); const log = debug('hooks');
@ -23,6 +24,6 @@ export const handle: Handle = async ({ event, resolve }) => {
} }
event.locals.session = session; event.locals.session = session;
event.locals.userId = session?.userId ?? null; event.locals.userId = session?.userId as UserId ?? null;
return resolve(event); return resolve(event);
}; };

View file

@ -0,0 +1,93 @@
<script lang="ts" generics="T">
import { createCombobox, melt } from '@melt-ui/svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
type Props = {
options?: T[];
getOptions?: (searchTerm: string) => Promise<T[]>;
itemToString?: (item: T) => string;
selectedItem: T | undefined;
error?: string;
} & HTMLInputAttributes;
let {
getOptions,
itemToString,
selectedItem = $bindable(),
error,
class: cls,
...rest
}: Props = $props();
const {
elements: { menu, input, option },
states: { open, inputValue, touchedInput, selected },
helpers: { isSelected }
} = createCombobox<T>({
forceVisible: false
});
let options = $derived.by(() => {
if ($touchedInput) {
return getOptions?.($inputValue);
}
if (selectedItem) {
return Promise.resolve([selectedItem]);
}
return Promise.resolve([]);
});
// initialize the input field if we're loading an already existing value
if (selectedItem) {
selected.set({
value: selectedItem
});
}
$effect(() => {
selectedItem = $selected?.value;
if (!$open) {
if ($selected) {
$inputValue = itemToString?.($selected?.value) ?? String($selected.value);
} else {
$inputValue = '';
}
}
});
$inspect(selectedItem, $selected?.value);
</script>
<div>
<input
type="text"
class="w-full {error ? 'border-red-500' : ''} {cls}"
{...rest}
placeholder="Search for user's email address"
use:melt={$input}
/>
{#if error}
<p class="text-sm text-red-500">{error}</p>
{/if}
</div>
{#if open}
<ul
use:melt={$menu}
class="z-10 flex max-h-[300px] flex-col overflow-hidden border border-gray-300 bg-white"
>
{#await options}
<p>Loading...</p>
{:then options}
{#each options ?? [] as entry}
<li
class="data-[highlighted]:bg-gray-200"
use:melt={$option({
value: entry
})}
>
{itemToString?.(entry) ?? String(entry)}
</li>
{/each}
{/await}
</ul>
{/if}

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
type Props = {
kind: 'primary' | 'secondary' | 'tertiary' | 'ghost';
} & HTMLButtonAttributes;
const { kind, children, class: cls, ...rest }: Props = $props();
const styles = {
primary: 'bg-sky-200 hover:bg-sky-300 text-black border border-black rounded',
secondary: 'bg-slate-200 hover:bg-slate-300 text-black border border-black rounded',
tertiary: 'bg-white hover:bg-slate-100 text-black border border-slate-500 rounded',
ghost: 'bg-white hover:bg-slate-100 text-sky-600'
};
</script>
<button
class="{styles[
kind
]} disabled:cursor-not-allowed disabled:border-slate-400 disabled:bg-slate-100 disabled:text-slate-400 {cls}"
{...rest}>{@render children?.()}</button
>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
const { children, class: cls, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div
class="bold mx-auto max-w-xl border-2 border-red-700 bg-red-100 text-center text-red-500 {cls}"
{...rest}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { browser } from '$app/environment';
import { AnyoneMarker, type Email, type User, type UserId } from '$lib/types';
import Autocomplete from './Autocomplete.svelte';
import Button from './Button.svelte';
import ProfileIcon from './icons/ProfileIcon.svelte';
import XMark from './icons/XMark.svelte';
type Props = {
user: User | null;
error?: string;
};
let { error, user = $bindable() }: Props = $props();
async function getUsers(searchTerm: string) {
if (browser) {
try {
const result = await fetch(`../../users?query=${searchTerm}`);
return (await result.json()) as User[];
} catch (e) {
// ignore the errors because it's most likely a 400
}
return [];
} else {
// prevent breaking on the server side because we can have relative URLs in fetch
return [];
}
}
const EMPTY_USER: User = {
id: 0 as UserId,
email: '' as Email
};
</script>
{#if user}
<div class="relative">
<Autocomplete
name="users"
getOptions={getUsers}
itemToString={(user) => user.email}
bind:selectedItem={user}
{error}
required
/>
<Button
type="button"
kind="ghost"
title="Allow anyone"
class="absolute right-1 top-2"
onclick={() => {
user = null;
}}><XMark /></Button
>
</div>
<input type="hidden" name="userIds" value={user.id} />
{:else}
<div class="flex gap-1">
<div class="flex-grow">
Anyone<input type="hidden" name="users" value={AnyoneMarker} />
<input type="hidden" name="userIds" value={AnyoneMarker} />
</div>
<Button
type="button"
kind="ghost"
title="Set user"
onclick={() => {
user = { ...EMPTY_USER };
}}><ProfileIcon /></Button
>
</div>
{/if}

View file

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

View file

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>

After

Width:  |  Height:  |  Size: 225 B

View file

@ -0,0 +1,13 @@
import { error } from "@sveltejs/kit";
import debug from "debug";
const log = debug('types');
export function parse<T extends number>(value: string): T {
const parsed = parseInt(value);
if (isNaN(parsed)) {
log('Invalid ID %s', value);
error(400, 'Invalid ID');
}
return parsed as T;
}

View file

@ -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<UserId> {
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
}

View file

@ -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<T>(survey: SurveyMetaData, accessLevel: AccessLevel, action: () => T): T | null {
if (checkAccess(survey, accessLevel)) {
return action();
}
return null;
}

View file

@ -2,6 +2,7 @@ import { db } from '../../db';
import { sessions } from '../../db/schema'; import { sessions } from '../../db/schema';
import { eq, lt } from 'drizzle-orm'; import { eq, lt } from 'drizzle-orm';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import type { UserId } from '$lib/types';
export type Session = { export type Session = {
userId: number; 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 db.update(sessions).set({ expires: newExpires }).where(eq(sessions.token, token)); // refresh the session as long as the user is working in it
return { return {
...session[0], ...session[0],
userId: session[0].userId as UserId,
expires: newExpires expires: newExpires
}; };
} }

View file

@ -2,4 +2,70 @@ import type { Branded } from "./branded";
export type Email = Branded<string, 'email'>; export type Email = Branded<string, 'email'>;
export type Password = Branded<string, 'password'>; export type Password = Branded<string, 'password'>;
export type AccessToken = Branded<string, 'accessToken'>;
export type VerificationCode = Branded<string, 'verificationCode'>; export type VerificationCode = Branded<string, 'verificationCode'>;
export type SurveyId = Branded<number, 'surveyId'>;
export type UserId = Branded<number, 'userId'>;
export type ParticipantId = Branded<number, 'participantId'>;
export type SkillId = Branded<number, 'skillId'>;
/// 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[];
}

View file

@ -1,33 +1,16 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { db } from '../../db';
import { surveyAccessTable, surveyAnswersTable, surveysTable } from '../../db/schema';
import type { PageServerLoad } from './$types'; 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 }) => { export const load: PageServerLoad = async ({ locals }) => {
if (!locals.userId) { if (!locals.userId) {
error(403, 'User is not logged in'); error(403, 'User is not logged in');
} }
const mySurveys = await db.select().from(surveysTable).where(eq(surveysTable.owner, locals.userId)); const mySurveys = await loadMySurveys(locals.userId);
const fillRates = await db.select({ const surveyWithFillRates = await loadSurveyFillRates(mySurveys);
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);
return { return {
surveys: mySurveys.map(survey => ({ surveys: surveyWithFillRates
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
}
}))
} }
} }

View file

@ -38,12 +38,14 @@
<li class="grid grid-cols-2"> <li class="grid grid-cols-2">
<div> <div>
<Link href="survey/{survey.id}">{survey.title}</Link> <Link href="survey/{survey.id}">{survey.title}</Link>
{#if survey.fillRate}
<span class="mr-5 inline-block"> <span class="mr-5 inline-block">
({survey.fillRate.filled}/{survey.fillRate.expected}) ({survey.fillRate.filled}/{survey.fillRate.expected})
{#if survey.fillRate.filled === survey.fillRate.expected && survey.fillRate.expected > 0} {#if survey.fillRate.filled === survey.fillRate.expected && survey.fillRate.expected > 0}
<CheckIcon /> <CheckIcon />
{/if} {/if}
</span> </span>
{/if}
</div> </div>
<div> <div>
<Link <Link

View file

@ -4,16 +4,22 @@ import type { PageServerLoad, RouteParams } from './$types';
import debug from 'debug'; import debug from 'debug';
import { deleteAnswers } from '../../../../db/answers'; import { deleteAnswers } from '../../../../db/answers';
import { loadSurveyData } from '../../../../db/survey'; import { loadSurveyData } from '../../../../db/survey';
import { AccessLevel, type SurveyId } from '$lib/types';
import { ensureAccess } from '$lib/helpers/backend/permissions';
import { parse } from '$lib/helpers/backend/parse';
const log = debug('survey:admin'); const log = debug('survey:admin');
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
return await loadSurveyData(params.surveyId, locals.userId); await ensureAccess(parse<SurveyId>(params.surveyId), locals.userId, AccessLevel.Clone, 'The current user cannot view this survey');
return await loadSurveyData(parse<SurveyId>(params.surveyId), locals.userId);
} }
export const actions = { export const actions = {
deleteAnswers: async ({ params, locals, request }) => { deleteAnswers: async ({ params, locals, request }) => {
const survey = await loadSurveyData(params.surveyId, locals.userId); await ensureAccess(parse<SurveyId>(params.surveyId), locals.userId, AccessLevel.Edit, 'The current user cannot edit this survey');
const survey = await loadSurveyData(parse<SurveyId>(params.surveyId), locals.userId);
let formData = await request.formData(); let formData = await request.formData();
const participantId = parseInt(formData.get('participantId')?.toString() ?? ''); const participantId = parseInt(formData.get('participantId')?.toString() ?? '');

View file

@ -14,6 +14,9 @@
import DeleteIcon from '$lib/components/icons/DeleteIcon.svelte'; import DeleteIcon from '$lib/components/icons/DeleteIcon.svelte';
import WarningDialog from '$lib/components/WarningDialog.svelte'; import WarningDialog from '$lib/components/WarningDialog.svelte';
import EditIcon from '$lib/components/icons/EditIcon.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(); let { data }: { data: PageData } = $props();
@ -21,14 +24,14 @@
if (data.participants.length > 0) { if (data.participants.length > 0) {
return data.participants.map((participant) => ({ return data.participants.map((participant) => ({
skill: skill.title, skill: skill.title,
participant: participant.id, participant: participant.id as ParticipantId,
rating: participant.answers.find((answer) => answer.skillId === skill.id)?.rating rating: participant.answers.find((answer) => answer.skillId === skill.id)?.rating
})); }));
} else { } else {
// fallback pseudo participant because empty diagram data will break the diagram and make the survey page unaccessible // fallback pseudo participant because empty diagram data will break the diagram and make the survey page unaccessible
return { return {
skill: skill.title, skill: skill.title,
participant: -1, participant: -1 as ParticipantId,
rating: undefined rating: undefined
}; };
} }
@ -43,6 +46,8 @@
let participantAnswersDeletionCandidateId = $state<number | null>(null); let participantAnswersDeletionCandidateId = $state<number | null>(null);
$inspect(data);
async function deleteSurvey() { async function deleteSurvey() {
await fetch('', { await fetch('', {
method: 'DELETE' method: 'DELETE'
@ -56,6 +61,7 @@
<a href="/" class="flex items-center" aria-label="Home" title="Home"> <a href="/" class="flex items-center" aria-label="Home" title="Home">
<HomeIcon /> <HomeIcon />
</a> </a>
{#if checkAccess(data, AccessLevel.Clone)}
<a <a
href="../survey/new?from={data.id}" href="../survey/new?from={data.id}"
class="ml-2 flex items-center" class="ml-2 flex items-center"
@ -63,6 +69,8 @@
title="Duplicate" title="Duplicate"
><DuplicateIcon /> ><DuplicateIcon />
</a> </a>
{/if}
{#if checkAccess(data, AccessLevel.Edit)}
<a <a
href="{data.id.toString()}/edit" href="{data.id.toString()}/edit"
class="ml-2 flex items-center" class="ml-2 flex items-center"
@ -70,6 +78,16 @@
title="Edit survey" title="Edit survey"
><EditIcon /> ><EditIcon />
</a> </a>
{/if}
{#if checkAccess(data, AccessLevel.Owner)}
<a
href="{data.id.toString()}/share"
class="ml-2 flex items-center"
aria-label="Share"
title="Share survey with other users"
>
<ShareIcon />
</a>
<button <button
onclick={() => { onclick={() => {
deleteSurveyDialogRef?.showModal(); deleteSurveyDialogRef?.showModal();
@ -79,6 +97,7 @@
title="Delete" title="Delete"
><TrashIcon /> ><TrashIcon />
</button> </button>
{/if}
</div> </div>
</Navbar> </Navbar>
{#if data.description} {#if data.description}
@ -95,6 +114,7 @@
<CheckIcon /> <CheckIcon />
{/if} {/if}
</span> </span>
{#if participant.accessToken}
<button <button
aria-label="Copy link to clipboard" aria-label="Copy link to clipboard"
class="text-sky-700" class="text-sky-700"
@ -106,6 +126,7 @@
> >
<LinkIcon /> <LinkIcon />
</button> </button>
{/if}
{#if participant.answers.length > 0} {#if participant.answers.length > 0}
<button <button
aria-label="Reset ratings" aria-label="Reset ratings"
@ -123,9 +144,13 @@
{/each} {/each}
</ul> </ul>
{#if checkAccess(data, AccessLevel.ReadResult)}
<div class="grid grid-cols-1 justify-items-center"> <div class="grid grid-cols-1 justify-items-center">
<Diagram data={diagramData} /> <Diagram data={diagramData} />
</div> </div>
{:else}
<div class="text-center">You do not have permission to see the survey results</div>
{/if}
<WarningDialog title="Delete survey" bind:dialogRef={deleteSurveyDialogRef} onAccept={deleteSurvey}> <WarningDialog title="Delete survey" bind:dialogRef={deleteSurveyDialogRef} onAccept={deleteSurvey}>
<p>Are you sure you want to delete the survey.</p> <p>Are you sure you want to delete the survey.</p>

View file

@ -7,19 +7,20 @@ import { db } from '../../../../../db';
import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from '../../../../../db/schema'; import { surveyAccessTable, surveyAnswersTable, surveySkillsTable, surveysTable } from '../../../../../db/schema';
import { eq, inArray, and } from 'drizzle-orm'; import { eq, inArray, and } from 'drizzle-orm';
import { addParticipant, addSkill, loadSurveyData } from '../../../../../db/survey'; import { addParticipant, addSkill, loadSurveyData } from '../../../../../db/survey';
import { ensureAccess } from '$lib/helpers/backend/permissions';
import { parse } from '$lib/helpers/backend/parse';
import { AccessLevel, type SurveyId } from '$lib/types';
const log = debug('survey:admin:edit'); const log = debug('survey:admin:edit');
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
return await loadSurveyData(params.surveyId, locals.userId); await ensureAccess(parse<SurveyId>(params.surveyId), locals.userId, AccessLevel.Edit, 'The current user cannot edit this survey');
return await loadSurveyData(parse<SurveyId>(params.surveyId), locals.userId);
} }
export const actions = { export const actions = {
default: async ({ request, params, locals }) => { default: async ({ request, params, locals }) => {
const owner = locals.userId; await ensureAccess(parse<SurveyId>(params.surveyId), locals.userId, AccessLevel.Edit, 'The current user cannot edit this survey');
if (!owner) {
error(400, 'User is not logged in');
}
const formData = await request.formData(); const formData = await request.formData();
@ -32,7 +33,8 @@ export const actions = {
error(400, 'At least one skill is required'); error(400, 'At least one skill is required');
} }
const survey = await loadSurveyData(params.surveyId, locals.userId); // TODO this should probably be handled in a single transaction
const survey = await loadSurveyData(parse<SurveyId>(params.surveyId), locals.userId);
// update the actual survey object // update the actual survey object
await db.update(surveysTable).set({ title, description }).where(eq(surveysTable.id, survey.id)); await db.update(surveysTable).set({ title, description }).where(eq(surveysTable.id, survey.id));

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import InlineWarning from '$lib/components/InlineWarning.svelte';
import Navbar from '$lib/components/Navbar.svelte'; import Navbar from '$lib/components/Navbar.svelte';
import SurveyEditForm from '$lib/components/SurveyEditForm.svelte'; import SurveyEditForm from '$lib/components/SurveyEditForm.svelte';
import type { PageData } from '../$types'; import type { PageData } from '../$types';
@ -8,13 +9,13 @@
<Navbar title="Edit survey" /> <Navbar title="Edit survey" />
{#if data.participants.some((participant) => participant.answers.length > 0)} {#if data.participants.some((participant) => participant.answers.length > 0)}
<div class="bold mx-auto max-w-xl border-2 border-red-700 bg-red-100 text-center text-red-500"> <InlineWarning>
<p>There are participants who have already submitted ratings.</p> <p>There are participants who have already submitted ratings.</p>
<p> <p>
Changing the structure of this survey (i.e. changing any of the skills) will potentially Changing the structure of this survey (i.e. changing any of the skills) will potentially
invalidate their answers. Consider either deleting their answers before changing the skills or invalidate their answers. Consider either deleting their answers before changing the skills or
leave the skills as is (e.g. only add/remove participants). leave the skills as is (e.g. only add/remove participants).
</p> </p>
</div> </InlineWarning>
{/if} {/if}
<SurveyEditForm {...data} submitButtonTitle="Save" /> <SurveyEditForm {...data} submitButtonTitle="Save" />

View file

@ -0,0 +1,40 @@
import { parse } from "$lib/helpers/backend/parse";
import { ensureAccess } from "$lib/helpers/backend/permissions";
import { AccessLevel, AnyoneMarker, type SurveyId, type UserId } from "$lib/types";
import { error, redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "../$types";
import { loadSurveyMetadata, loadSurveyPermissions, setPermissions, type PermissionEntry } from "../../../../../db/survey";
export const load: PageServerLoad = async ({ params, locals }) => {
const surveyId = parse<SurveyId>(params.surveyId);
let userId = await ensureAccess(surveyId, locals.userId, AccessLevel.Owner, "Only the survey owner can give access to the survey");
const survey = await loadSurveyMetadata(surveyId, userId);
const permissions = await loadSurveyPermissions(surveyId);
return { survey, permissions: permissions.map(p => ({ user: p.user, access: p.access })) };
}
export const actions = {
default: async ({ request, params, locals }) => {
const surveyId = parse<SurveyId>(params.surveyId);
await ensureAccess(surveyId, locals.userId, AccessLevel.Owner, 'The current user cannot edit the permissions of this survey');
const formData = await request.formData();
const permissions = formData.getAll("permissions");
const userPermissions: PermissionEntry[] = formData.getAll("userIds").map((userId, index) => ({
user: userId === AnyoneMarker ? null : parse<UserId>(userId.toString()),
access: parse<AccessLevel>(permissions[index].toString())
}));
// some basic validation to prevent users from creating surveys without owners
if (!userPermissions.some(p => p.access === AccessLevel.Owner)) {
console.log("No owner", userPermissions);
error(400, "A survey must have at least one owner");
}
await setPermissions(surveyId, userPermissions);
redirect(303, `.`);
}
}

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { browser } from '$app/environment';
import Autocomplete from '$lib/components/Autocomplete.svelte';
import Button from '$lib/components/Button.svelte';
import Navbar from '$lib/components/Navbar.svelte';
import { AccessLevel, AnyoneMarker } from '$lib/types';
import type { UserId, Email } from '$lib/types';
import type { PageData } from './$types';
import InlineWarning from '$lib/components/InlineWarning.svelte';
import TrashIcon from '$lib/components/icons/TrashIcon.svelte';
import UserSelector from '$lib/components/UserSelector.svelte';
let { data }: { data: PageData } = $props();
let permissions = $state(data.permissions);
const hasOwner = $derived(permissions.some((p) => p.access === AccessLevel.Owner));
const isDuplicated = $derived(
permissions.map((p, idx) =>
permissions.some((candidate, cidx) => idx !== cidx && p.user?.id === candidate.user?.id)
)
);
const hasErrors = $derived(!hasOwner || isDuplicated.some((b) => b));
</script>
<Navbar title="Permissions for: {data.survey?.title}" />
{#if !hasOwner}
<InlineWarning class="mb-5">
<p>A survey MUST have at least one owner.</p>
</InlineWarning>
{/if}
<form method="POST" class="mx-4 grid grid-cols-[1fr_1fr_max-content] gap-2">
{#each permissions as permission, idx}
<UserSelector
bind:user={permission.user}
error={isDuplicated[idx] ? 'Cannot add user twice' : undefined}
/>
<select name="permissions" bind:value={permission.access}>
<option value={AccessLevel.Clone}>Clone</option>
<option value={AccessLevel.ReadResult}>Read results</option>
<option value={AccessLevel.Edit}>Edit</option>
<option value={AccessLevel.Owner}>Owner</option>
</select>
<div>
<Button kind="ghost" onclick={() => permissions.splice(idx, 1)} title="Remove entry"
><TrashIcon /></Button
>
</div>
{/each}
<Button
class="col-[2] w-40 justify-self-center"
kind="secondary"
onclick={(ev) => {
ev.preventDefault();
permissions.push({
user: {
email: '' as Email,
id: 0 as UserId
},
access: AccessLevel.ReadResult
});
}}>Add user</Button
>
<Button
type="submit"
kind="primary"
class="col-span-2 w-40 justify-self-center p-3"
disabled={hasErrors}>Update permissions</Button
>
</form>

View file

@ -1,14 +1,18 @@
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import { surveysTable } from '../../../../db/schema'; import { surveyPermissionsTable, surveysTable } from '../../../../db/schema';
import { db } from '../../../../db'; import { db } from '../../../../db';
import { fromFormData } from '$lib/survey'; import { fromFormData } from '$lib/survey';
import { addParticipant, addSkill, loadSurvey } from '../../../../db/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 }) => { export const load: PageServerLoad = async ({ url, locals }) => {
const baseSurveyId = url.searchParams.get('from'); const baseSurveyId = url.searchParams.get('from');
if (baseSurveyId) { if (baseSurveyId && locals.userId) {
const baseSurvey = await loadSurvey(parseInt(baseSurveyId), locals.userId ?? 0); await ensureAccess(parse<SurveyId>(baseSurveyId), locals.userId, AccessLevel.Clone, 'The current user cannot clone this survey');
const baseSurvey = await loadSurvey(parse<SurveyId>(baseSurveyId), locals.userId);
return baseSurvey; return baseSurvey;
} }
return null; return null;
@ -35,9 +39,13 @@ export const actions = {
error(400, 'At least one skill is required'); 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 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) { for (const participant of participants) {
await addParticipant(surveyId, participant); await addParticipant(surveyId, participant);
} }

View file

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

View file

@ -6,10 +6,10 @@ import { usersTable } from '../../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { verify } from '@node-rs/argon2'; import { verify } from '@node-rs/argon2';
import { generateRandomToken } from '$lib/randomToken';
import debug from 'debug'; import debug from 'debug';
import { config } from '$lib/configuration'; import { config } from '$lib/configuration';
import { generateRandomToken } from '$lib/helpers/shared/randomToken';
let log = debug('login'); let log = debug('login');

View file

@ -1,11 +1,15 @@
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { preprocessMeltUI, sequence } from '@melt-ui/pp'
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations // Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: sequence([
vitePreprocess(),
preprocessMeltUI() // add to the end!
]),
kit: { kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.