diff --git a/package.json b/package.json index 95f1ebe..2bc22ba 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "pdfkit": "^0.17.0", "pdfmake": "^0.2.18", "pinia": "^3.0.2", + "pino": "^9.6.0", + "pino-abstract-transport": "^2.0.0", + "pino-http": "^10.4.0", + "pino-pretty": "^13.0.0", "quasar": "^2.16.0", "uuid": "^11.1.0", "vue": "^3.4.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43f92b8..f1cf1c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,18 @@ importers: pinia: specifier: ^3.0.2 version: 3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)) + pino: + specifier: ^9.6.0 + version: 9.6.0 + pino-abstract-transport: + specifier: ^2.0.0 + version: 2.0.0 + pino-http: + specifier: ^10.4.0 + version: 10.4.0 + pino-pretty: + specifier: ^13.0.0 + version: 13.0.0 quasar: specifier: ^2.16.0 version: 2.18.1 @@ -876,6 +888,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + autoprefixer@10.4.21: resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -1059,6 +1075,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} @@ -1152,6 +1171,9 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -1447,6 +1469,9 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} + fast-copy@3.0.2: + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1466,6 +1491,13 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1645,6 +1677,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -1803,6 +1838,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + jpeg-exif@1.1.4: resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==} @@ -2075,6 +2114,10 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2196,6 +2239,23 @@ packages: typescript: optional: true + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-http@10.4.0: + resolution: {integrity: sha512-vjQsKBE+VN1LVchjbfLE7B6nBeGASZNRNKsR68VS0DolTm5R3zo+47JX1wjm0O96dcbvA7vnqt8YqOWlG5nN0w==} + + pino-pretty@13.0.0: + resolution: {integrity: sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==} + hasBin: true + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.6.0: + resolution: {integrity: sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==} + hasBin: true + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -2244,6 +2304,9 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -2287,6 +2350,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + random-bytes@1.0.0: resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} engines: {node: '>= 0.8'} @@ -2328,6 +2394,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -2396,6 +2466,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2530,6 +2604,9 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -2617,6 +2694,9 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2636,6 +2716,10 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stack-trace@1.0.0-pre2: resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} engines: {node: '>=16'} @@ -2723,6 +2807,9 @@ packages: text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -3854,6 +3941,8 @@ snapshots: asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} + autoprefixer@10.4.21(postcss@8.5.3): dependencies: browserslist: 4.24.4 @@ -4060,6 +4149,8 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + colorjs.io@0.5.2: {} combined-stream@1.0.8: @@ -4149,6 +4240,8 @@ snapshots: date-fns@4.1.0: {} + dateformat@4.6.3: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -4499,6 +4592,8 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 + fast-copy@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -4517,6 +4612,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-redact@3.5.0: {} + + fast-safe-stringify@2.1.1: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -4717,6 +4816,8 @@ snapshots: he@1.2.0: {} + help-me@5.0.0: {} + hookable@5.5.3: {} html-minifier-terser@7.2.0: @@ -4876,6 +4977,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + joycon@3.1.1: {} + jpeg-exif@1.1.4: {} js-tokens@4.0.0: {} @@ -5129,6 +5232,8 @@ snapshots: object-keys@1.1.1: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -5259,6 +5364,49 @@ snapshots: optionalDependencies: typescript: 5.8.3 + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-http@10.4.0: + dependencies: + get-caller-file: 2.0.5 + pino: 9.6.0 + pino-std-serializers: 7.0.0 + process-warning: 4.0.1 + + pino-pretty@13.0.0: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pump: 3.0.2 + secure-json-parse: 2.7.0 + sonic-boom: 4.2.0 + strip-json-comments: 3.1.1 + + pino-std-serializers@7.0.0: {} + + pino@9.6.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 4.0.1 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -5315,6 +5463,8 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} + process@0.11.10: {} proxy-addr@2.0.7: @@ -5349,6 +5499,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + random-bytes@1.0.0: {} randombytes@2.1.0: @@ -5405,6 +5557,8 @@ snapshots: readdirp@4.1.2: {} + real-require@0.2.0: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -5482,6 +5636,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sass-embedded-android-arm64@1.87.0: @@ -5580,6 +5736,8 @@ snapshots: sax@1.4.1: {} + secure-json-parse@2.7.0: {} + selderee@0.11.0: dependencies: parseley: 0.12.1 @@ -5698,6 +5856,10 @@ snapshots: dependencies: semver: 7.7.1 + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5711,6 +5873,8 @@ snapshots: speakingurl@14.0.1: {} + split2@4.2.0: {} + stack-trace@1.0.0-pre2: {} statuses@2.0.1: {} @@ -5813,6 +5977,10 @@ snapshots: dependencies: b4a: 1.6.7 + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + tiny-inflate@1.0.3: {} tiny-invariant@1.3.3: {} diff --git a/prisma/migrations/20250425181700_add_log_table/migration.sql b/prisma/migrations/20250425181700_add_log_table/migration.sql new file mode 100644 index 0000000..80f2b98 --- /dev/null +++ b/prisma/migrations/20250425181700_add_log_table/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "Log" ( + "id" SERIAL NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "level" TEXT NOT NULL, + "message" TEXT NOT NULL, + "meta" JSONB, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 09371f9..27c2fa7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -129,3 +129,11 @@ model Session { data String expiresAt DateTime @map("expires_at") } + +model Log { + id Int @id @default(autoincrement()) + timestamp DateTime @default(now()) + level String + message String + meta Json? // Optional field for additional structured data +} diff --git a/quasar.config.js b/quasar.config.js index 3c2c539..0c26a16 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -90,7 +90,7 @@ export default defineConfig((/* ctx */) => // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework framework: { config: { - dark: true + dark: 'auto' }, // iconSet: 'material-icons', // Quasar icon set diff --git a/src-server/database.js b/src-server/database.js index 23261de..969ce7e 100644 --- a/src-server/database.js +++ b/src-server/database.js @@ -4,4 +4,16 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // Export the Prisma Client instance for use in other modules -export default prisma; \ No newline at end of file +export default prisma; + +// Helper function for consistent error handling +export const handlePrismaError = (res, err, context) => +{ + console.error(`Error ${context}:`, err.message); + // Basic error handling, can be expanded (e.g., check for Prisma-specific error codes) + if (err.code === 'P2025') + { // Prisma code for record not found + return res.status(404).json({ error: `${context}: Record not found` }); + } + res.status(500).json({ error: `Failed to ${context}: ${err.message}` }); +}; \ No newline at end of file diff --git a/src-server/routes/api.js b/src-server/routes/api.js index 8931d03..441d375 100644 --- a/src-server/routes/api.js +++ b/src-server/routes/api.js @@ -1,5 +1,6 @@ import { Router } from 'express'; import prisma from '../database.js'; +import { handlePrismaError } from '../database.js'; import PDFDocument from 'pdfkit'; import { join } from 'path'; import { generateTodaysSummary } from '../services/mantisSummarizer.js'; @@ -9,18 +10,6 @@ const router = Router(); const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, ''); -// Helper function for consistent error handling -const handlePrismaError = (res, err, context) => -{ - console.error(`Error ${context}:`, err.message); - // Basic error handling, can be expanded (e.g., check for Prisma-specific error codes) - if (err.code === 'P2025') - { // Prisma code for record not found - return res.status(404).json({ error: `${context}: Record not found` }); - } - res.status(500).json({ error: `Failed to ${context}: ${err.message}` }); -}; - // --- Forms API --- // // GET /api/forms - List all forms @@ -632,7 +621,6 @@ router.post('/mantis-summaries/generate', async(req, res) => generateTodaysSummary() .then(() => { - console.log('Summary generation process finished successfully (async).'); }) .catch(error => { @@ -647,59 +635,4 @@ router.post('/mantis-summaries/generate', async(req, res) => } }); -// --- Settings API --- // - -// GET /api/settings/:key - Get a specific setting value -router.get('/settings/:key', async(req, res) => -{ - const { key } = req.params; - try - { - const setting = await prisma.setting.findUnique({ - where: { key: key }, - select: { value: true } - }); - - if (setting !== null) - { - res.json({ key, value: setting.value }); - } - else - { - res.json({ key, value: '' }); // Return empty value if not found - } - } - catch (err) - { - handlePrismaError(res, err, `fetch setting '${key}'`); - } -}); - -// PUT /api/settings/:key - Update or create a specific setting -router.put('/settings/:key', async(req, res) => -{ - const { key } = req.params; - const { value } = req.body; - - if (typeof value === 'undefined') - { - return res.status(400).json({ error: 'Setting value is required in the request body' }); - } - - try - { - const upsertedSetting = await prisma.setting.upsert({ - where: { key: key }, - update: { value: String(value) }, - create: { key: key, value: String(value) }, - select: { key: true, value: true } // Select to return the updated/created value - }); - res.status(200).json(upsertedSetting); - } - catch (err) - { - handlePrismaError(res, err, `update setting '${key}'`); - } -}); - export default router; \ No newline at end of file diff --git a/src-server/routes/auth.js b/src-server/routes/auth.js index 9dfe52b..b69860b 100644 --- a/src-server/routes/auth.js +++ b/src-server/routes/auth.js @@ -48,7 +48,8 @@ async function getAuthenticatorByCredentialID(credentialID) // Generate Registration Options router.post('/generate-registration-options', async(req, res) => { - const { username } = req.body; + // Destructure username, email, and fullName from the request body + const { username, email, fullName } = req.body; if (!username) { @@ -59,13 +60,18 @@ router.post('/generate-registration-options', async(req, res) => { let user = await getUserByUsername(username); - // If user doesn't exist, create one + // If user doesn't exist, create one with the provided details if (!user) { + const userData = { username }; + if (email) userData.email = email; // Add email if provided + if (fullName) userData.fullName = fullName; // Add fullName if provided + user = await prisma.user.create({ - data: { username }, + data: userData, }); } + // ... rest of the existing logic ... const userAuthenticators = await getUserAuthenticators(user.id); @@ -107,6 +113,11 @@ router.post('/generate-registration-options', async(req, res) => catch (error) { console.error('Registration options error:', error); + // Handle potential Prisma unique constraint errors (e.g., email already exists) + if (error.code === 'P2002' && error.meta?.target?.includes('email')) + { + return res.status(409).json({ error: 'Email address is already in use.' }); + } res.status(500).json({ error: 'Failed to generate registration options' }); } }); @@ -147,8 +158,6 @@ router.post('/verify-registration', async(req, res) => const { verified, registrationInfo } = verification; - console.log(verification); - if (verified && registrationInfo) { const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo; @@ -190,12 +199,18 @@ router.post('/verify-registration', async(req, res) => } else { + // This else block was previously misplaced before the if block res.status(400).json({ error: 'Registration verification failed' }); } } catch (error) { console.error('Registration verification error:', error); + // Handle potential Prisma unique constraint errors (e.g., email already exists) + if (error.code === 'P2002' && error.meta?.target?.includes('email')) + { + return res.status(409).json({ error: 'Email address is already in use.' }); + } challengeStore.delete(userId); // Clean up challenge on error delete req.session.userId; res.status(500).json({ error: 'Failed to verify registration', details: error.message }); @@ -225,12 +240,8 @@ router.post('/generate-authentication-options', async(req, res) => return res.status(404).json({ error: 'User not found' }); } - console.log('User found:', user); - const userAuthenticators = await getUserAuthenticators(user.id); - console.log('User authenticators:', userAuthenticators); - const options = await generateAuthenticationOptions({ rpID, // Require users to use a previously-registered authenticator @@ -437,7 +448,8 @@ router.get('/status', async(req, res) => {}); return res.status(401).json({ status: 'unauthenticated' }); } - return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } }); + // Include email and fullName in the response + return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email, fullName: user.fullName } }); } res.json({ status: 'unauthenticated' }); }); diff --git a/src-server/routes/settings.js b/src-server/routes/settings.js new file mode 100644 index 0000000..7ef0185 --- /dev/null +++ b/src-server/routes/settings.js @@ -0,0 +1,51 @@ +import express from 'express'; +import { getSetting, setSetting, getUserPreference, setUserPreference } from '../utils/settings.js'; +import { handlePrismaError } from '../database.js'; + +const router = express.Router(); + + +// --- Settings API --- // + +// GET /api/settings/:key - Get a specific setting value +router.get('/:key', async(req, res) => +{ + const { key } = req.params; + try + { + const setting = await getSetting(key); + if (!setting) + { + return res.status(404).json({ error: `Setting '${key}' not found` }); + } + res.status(200).json(setting); + } + catch (err) + { + handlePrismaError(res, err, `fetch setting '${key}'`); + } +}); + +// PUT /api/settings/:key - Update or create a specific setting +router.put('/:key', async(req, res) => +{ + const { key } = req.params; + const { value } = req.body; + + if (typeof value === 'undefined') + { + return res.status(400).json({ error: 'Setting value is required in the request body' }); + } + + try + { + await setSetting(key, value); + res.status(200).json({ message: `Setting '${key}' updated successfully` }); + } + catch (err) + { + handlePrismaError(res, err, `update setting '${key}'`); + } +}); + +export default router; \ No newline at end of file diff --git a/src-server/routes/userPreferences.js b/src-server/routes/userPreferences.js new file mode 100644 index 0000000..3179999 --- /dev/null +++ b/src-server/routes/userPreferences.js @@ -0,0 +1,57 @@ +import express from 'express'; + +import { getUserPreference, setUserPreference } from '../utils/settings.js'; + +const router = express.Router(); + +// GET /api/user-preferences/:key - Get a user preference for the logged-in user +router.get('/:key', async(req, res) => +{ + const { key } = req.params; + const userId = req.session?.loggedInUserId; + if (!userId) + { + return res.status(401).json({ error: 'Not authenticated' }); + } + try + { + const value = await getUserPreference(userId, key); + if (typeof value === 'undefined' || value === null) + { + return res.status(404).json({ error: `Preference '${key}' not found for user` }); + } + res.status(200).json({ key, value }); + } + catch (err) + { + handlePrismaError(res, err, `fetch user preference '${key}'`); + } +}); + +// PUT /api/user-preferences/:key - Set a user preference for the logged-in user +router.put('/:key', async(req, res) => +{ + const { key } = req.params; + const { value } = req.body; + const userId = req.session?.loggedInUserId; + if (!userId) + { + return res.status(401).json({ error: 'Not authenticated' }); + } + if (typeof value === 'undefined') + { + return res.status(400).json({ error: 'Preference value is required in the request body' }); + } + try + { + await setUserPreference(userId, key, value); + res.status(200).json({ message: `Preference '${key}' updated for user` }); + } + catch (err) + { + handlePrismaError(res, err, `update user preference '${key}'`); + } +}); + + +export default router; \ No newline at end of file diff --git a/src-server/server.js b/src-server/server.js index 10716c4..854a018 100644 --- a/src-server/server.js +++ b/src-server/server.js @@ -12,33 +12,82 @@ import dotenv from 'dotenv'; import express from 'express'; import compression from 'compression'; -import session from 'express-session'; // Added for session management -import { PrismaSessionStore } from '@quixo3/prisma-session-store'; // Import Prisma session store -import { PrismaClient } from '@prisma/client'; // Import Prisma Client -import { v4 as uuidv4 } from 'uuid'; // Added for generating session IDs +import session from 'express-session'; +import { PrismaSessionStore } from '@quixo3/prisma-session-store'; +import { PrismaClient } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; +import pino from 'pino'; +import pinoHttp from 'pino-http'; import apiRoutes from './routes/api.js'; -import authRoutes from './routes/auth.js'; // Added for WebAuthn routes -import chatRoutes from './routes/chat.js'; // Added for Chat routes +import authRoutes from './routes/auth.js'; +import chatRoutes from './routes/chat.js'; +import settingsRoutes from './routes/settings.js'; +import userPreferencesRoutes from './routes/userPreferences.js'; import cron from 'node-cron'; import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js'; +import { requireAuth } from './middlewares/authMiddleware.js'; dotenv.config(); +// Initialize Pino logger +const targets = []; + +// Console logging (pretty-printed in development) +if (process.env.NODE_ENV !== 'production') +{ + targets.push({ + target: 'pino-pretty', + options: { + colorize: true + }, + level: process.env.LOG_LEVEL || 'info' + }); +} +else +{ + // Basic console logging in production + targets.push({ + target: 'pino/file', // Log to stdout in production + options: { destination: 1 }, // 1 is stdout + level: process.env.LOG_LEVEL || 'info' + }); +} + +// Database logging via custom transport +targets.push({ + target: './utils/prisma-pino-transport.js', // Path to the custom transport + options: {}, // No specific options needed for this transport + level: process.env.DB_LOG_LEVEL || 'info' // Separate level for DB logging if needed +}); + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', // Overall minimum level + transport: { + targets: targets + } +}); + +// Initialize pino-http middleware +const httpLogger = pinoHttp({ logger }); + // Define Relying Party details (Update with your actual details) -export const rpID = process.env.NODE_ENV === 'production' ? 'your-production-domain.com' : 'localhost'; +export const rpID = process.env.NODE_ENV === 'production' ? 'stylepoint.uk' : 'localhost'; export const rpName = 'StylePoint'; export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9000`; // In-memory store for challenges (Replace with a persistent store in production) export const challengeStore = new Map(); -const prisma = new PrismaClient(); // Instantiate Prisma Client +const prisma = new PrismaClient(); const app = express(); +// Add pino-http middleware +app.use(httpLogger); + if(!process.env.SESSION_SECRET) { - console.error('SESSION_SECRET environment variable is not set. Please set it to a strong secret key.'); + logger.error('SESSION_SECRET environment variable is not set. Please set it to a strong secret key.'); process.exit(1); // Exit the process if the secret is not set } @@ -67,15 +116,14 @@ app.use(session({ // Run daily at 1:00 AM server time (adjust as needed) cron.schedule('0 1 * * *', async() => { - console.log('Running scheduled Mantis summary task...'); try { await generateAndStoreMantisSummary(); - console.log('Scheduled Mantis summary task completed.'); + logger.info('Scheduled Mantis summary task completed successfully.'); } catch (error) { - console.error('Error running scheduled Mantis summary task:', error); + logger.error({ error }, 'Error running scheduled Mantis summary task'); } }, { scheduled: true, @@ -90,12 +138,12 @@ app.disable('x-powered-by'); app.use(express.json()); // Add API routes -app.use('/api', apiRoutes); app.use('/api/auth', authRoutes); -app.use('/api/chat', chatRoutes); +app.use('/api/chat', requireAuth, chatRoutes); +app.use('/api/user-preferences', requireAuth, userPreferencesRoutes); +app.use('/api/settings', requireAuth, settingsRoutes); +app.use('/api', requireAuth, apiRoutes); -// place here any middlewares that -// absolutely need to run before anything else if (process.env.PROD) { app.use(compression()); @@ -105,5 +153,5 @@ app.use(express.static('public', { index: false })); app.listen(8000, () => { - console.log('Server is running on http://localhost:8000'); + logger.info('Server is running on http://localhost:8000'); }); \ No newline at end of file diff --git a/src-server/services/mantisSummarizer.js b/src-server/services/mantisSummarizer.js index a1d7381..f1857ac 100644 --- a/src-server/services/mantisSummarizer.js +++ b/src-server/services/mantisSummarizer.js @@ -99,16 +99,11 @@ export async function generateAndStoreMantisSummary() { try { - // Get the prompt from the database settings using Prisma - const setting = await prisma.setting.findUnique({ - where: { key: 'mantisPrompt' }, - select: { value: true } - }); - const promptTemplate = setting?.value; + const promptTemplate = await getSetting('MANTIS_PROMPT'); if (!promptTemplate) { - console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.'); + console.error('Mantis prompt not found in database settings (key: MANTIS_PROMPT). Skipping summary generation.'); return; } @@ -118,16 +113,13 @@ export async function generateAndStoreMantisSummary() if (tickets.length === 0) { summaryText = 'No Mantis tickets updated recently.'; - console.log('No recent Mantis tickets found.'); } else { - console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`); let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]); prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2)); summaryText = await askGemini(prompt); - console.log('Mantis summary generated successfully by AI.'); } // Store the summary in the database using Prisma upsert @@ -144,7 +136,6 @@ export async function generateAndStoreMantisSummary() summaryText: summaryText, }, }); - console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`); } catch (error) diff --git a/src-server/utils/gemini.js b/src-server/utils/gemini.js index 1d8e32b..6aad99d 100644 --- a/src-server/utils/gemini.js +++ b/src-server/utils/gemini.js @@ -10,8 +10,6 @@ export async function askGemini(content) const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY'); - console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key - if (!GOOGLE_API_KEY) { throw new Error('Google API key is not set in the database.'); diff --git a/src-server/utils/prisma-pino-transport.js b/src-server/utils/prisma-pino-transport.js new file mode 100644 index 0000000..d29fd6d --- /dev/null +++ b/src-server/utils/prisma-pino-transport.js @@ -0,0 +1,47 @@ +import { PrismaClient } from '@prisma/client'; +import build from 'pino-abstract-transport'; + +const prisma = new PrismaClient(); + +export default async function(opts) +{ + return build(async(source) => + { + for await (const obj of source) + { + try + { + const { time, level, msg, ...meta } = obj; + // Pino levels are numeric, convert to string names if needed + const levelMap = { + '10': 'trace', + '20': 'debug', + '30': 'info', + '40': 'warn', + '50': 'error', + '60': 'fatal' + }; + const levelString = levelMap[level] || 'info'; // Default to info + + await prisma.log.create({ + data: { + timestamp: new Date(time), + level: levelString, + message: msg, + // Store remaining properties in the meta field if it exists + meta: Object.keys(meta).length > 0 ? meta : undefined, + }, + }); + } + catch (error) + { + console.error('Failed to write log to database:', error); + } + } + }, { + async close(err) + { + await prisma.$disconnect(); + } + }); +} diff --git a/src-server/utils/settings.js b/src-server/utils/settings.js index aa8be85..7be3841 100644 --- a/src-server/utils/settings.js +++ b/src-server/utils/settings.js @@ -7,14 +7,44 @@ export async function getSetting(key) select: { value: true } }); + console.log(`getSetting(${key})`, setting); + return setting?.value ? JSON.parse(setting.value) : null; } export async function setSetting(key, value) { + //Replace all CRLFs with LF + if (typeof value === 'string') + { + value = value.replace(/\r\n/g, '\n').trim(); + } + await prisma.setting.upsert({ where: { key }, update: { value: JSON.stringify(value) }, - create: { key, value } + create: { key, value: JSON.stringify(value) } + }); +} + +export async function getUserPreference(userId, key) +{ + const pref = await prisma.userPreference.findUnique({ + where: { userId_key: { userId, key } }, + select: { value: true } + }); + return pref?.value ? JSON.parse(pref.value) : null; +} + +export async function setUserPreference(userId, key, value) +{ + if (typeof value === 'string') + { + value = value.replace(/\r\n/g, '\n').trim(); + } + await prisma.userPreference.upsert({ + where: { userId_key: { userId, key } }, + update: { value: JSON.stringify(value) }, + create: { userId, key, value: JSON.stringify(value) } }); } \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 1f003d1..b23b108 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,12 +4,22 @@ diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 32c6f80..ccc0e6a 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -10,12 +10,13 @@ clickable v-ripple @click="toggleLeftDrawer" + class="relative" > - - + + StylePoint @@ -25,16 +26,41 @@ v-if="leftDrawerOpen" bordered flat - class="q-ma-sm text-center" + class="q-ma-sm text-center relative" > + + + + Passkey Management + + + + + + User Preferences + + - - {{ authStore.user.username }} -
{{ authStore.user.username }} @@ -54,6 +80,50 @@ class="menu-list" v-else > + + + Passkey Management + + + + + + + Passkey Management + + + + + + User Preferences + + + + + + + User Preferences + + + chatStore.isChatVisible); const chatMessages = computed(() => chatStore.chatMessages); diff --git a/src/pages/FormListPage.vue b/src/pages/FormListPage.vue index aefc2b9..6bab285 100644 --- a/src/pages/FormListPage.vue +++ b/src/pages/FormListPage.vue @@ -1,96 +1,100 @@ diff --git a/src/pages/LandingPage.vue b/src/pages/LandingPage.vue index cbc8f61..e355008 100644 --- a/src/pages/LandingPage.vue +++ b/src/pages/LandingPage.vue @@ -45,7 +45,7 @@ const $q = useQuasar(); const currentYear = ref(new Date().getFullYear()); const features = ref([ - 'Auatomated Daily Reports', + 'Automated Daily Reports', 'Deep Mantis Integration', 'Easy Authentication', 'And more..?' diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 5d863de..9af9a9d 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -80,12 +80,10 @@ async function handleLogin() if (verificationRes.data.verified) { - // Update the auth store on successful login authStore.isAuthenticated = true; authStore.user = verificationRes.data.user; - authStore.error = null; // Clear any previous errors - console.log('Login successful:', verificationRes.data.user); - router.push('/'); // Redirect to home page + authStore.error = null; + router.push('/'); } else { diff --git a/src/pages/MantisSummariesPage.vue b/src/pages/MantisSummariesPage.vue index fdc0e16..f91ff8d 100644 --- a/src/pages/MantisSummariesPage.vue +++ b/src/pages/MantisSummariesPage.vue @@ -5,7 +5,7 @@ bordered > -
+
Mantis Summaries
-
-
- Passkey Management -
-
- - -
-
+ + +
+ Passkey Management +
+
+ + +
+
- - -
Your Registered Passkeys
- - -
- {{ registerSuccessMessage }} -
-
- {{ registerErrorMessage }} -
-
- + + +
+ Registered Passkeys +
+ + +
- - Passkey ID: {{ passkey.credentialID }} - - Verified just now! - - - + {{ registerSuccessMessage }} +
+
+ {{ registerErrorMessage }} +
- +
+
- - - - - -
- Loading passkeys... -
-
- You have no passkeys registered yet. -
+ :class="{ 'bg-info': identifiedPasskeyId === passkey.credentialID }" + > + +
+ Passkey ID: + {{ passkey.credentialID }} +
+
+ {{ passkey.credentialID }} +
+ + Verified just now! + +
-
- {{ fetchErrorMessage }} -
-
- {{ deleteSuccessMessage }} -
-
- {{ deleteErrorMessage }} -
-
- {{ identifyErrorMessage }} -
- + + + + + Delete Passkey + + + +
+
+
+ Loading passkeys... +
+
+ You have no passkeys registered yet. +
+ +
+ {{ fetchErrorMessage }} +
+
+ {{ deleteSuccessMessage }} +
+
+ {{ deleteErrorMessage }} +
+
+ {{ identifyErrorMessage }} +
+
+
- - diff --git a/src/pages/UserPreferencesPage.vue b/src/pages/UserPreferencesPage.vue new file mode 100644 index 0000000..a858369 --- /dev/null +++ b/src/pages/UserPreferencesPage.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/router/routes.js b/src/router/routes.js index 5df8272..875e221 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -40,7 +40,6 @@ const routes = [ component: () => import('pages/PasskeyManagementPage.vue'), // Assuming this page exists or will be created meta: { requiresAuth: true, - navGroup: 'auth', // Show only when logged in icon: 'key', title: 'Passkeys', caption: 'Manage your passkeys' @@ -85,6 +84,14 @@ const routes = [ title: 'Settings', caption: 'Manage application settings' } + }, + { + path: 'user-preferences', + name: 'userPreferences', + component: () => import('pages/UserPreferencesPage.vue'), + meta: { + requiresAuth: true + } } ] }, diff --git a/src/stores/preferences.js b/src/stores/preferences.js new file mode 100644 index 0000000..1e16532 --- /dev/null +++ b/src/stores/preferences.js @@ -0,0 +1,115 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import axios from 'boot/axios'; +import { Dark } from 'quasar'; + +export const usePreferencesStore = defineStore('preferences', () => +{ + // Grouped user preferences structure (can be imported/shared if needed) + const preferences = ref({ + UI: [ + { + name: 'Theme', + key: 'theme', + type: 'text', + options: [ + { label: 'Light', value: 'light' }, + { label: 'Dark', value: 'dark' }, + ], + }, + ], + API_Tokens: [ + { + name: 'Mantis API Key', + key: 'MANTIS_API_KEY', + type: 'text', + } + ] + }); + + const values = ref({}); + const loading = ref(false); + const saving = ref(false); + const error = ref(null); + + async function loadPreferences() + { + loading.value = true; + error.value = null; + values.value = {}; + const allKeys = Object.values(preferences.value).flat().map(p => p.key); + try + { + const responses = await Promise.all( + allKeys.map(key => + axios.get(`/api/user-preferences/${key}`, { + validateStatus: status => status === 200 || status === 404, + }) + ) + ); + responses.forEach((response, idx) => + { + const key = allKeys[idx]; + if (response.status === 404) + { + values.value[key] = ''; + } + else + { + values.value[key] = response.data.value; + } + }); + } + catch (err) + { + error.value = err.response?.data?.error || 'Failed to load preferences.'; + } + finally + { + loading.value = false; + } + + //If we have a "theme" preference, change it in Quasar + if (values.value.theme) + { + Dark.set(values.value.theme === 'dark'); + } + } + + async function savePreferences() + { + saving.value = true; + error.value = null; + const allKeys = Object.keys(values.value); + const requests = allKeys.map(key => + axios.put(`/api/user-preferences/${key}`, { value: values.value[key] }) + ); + try + { + await Promise.all(requests); + } + catch (err) + { + error.value = err.response?.data?.error || 'Failed to save preferences.'; + } + finally + { + saving.value = false; + } + + if (values.value.theme) + { + Dark.set(values.value.theme === 'dark'); + } + } + + return { + preferences, + values, + loading, + saving, + error, + loadPreferences, + savePreferences, + }; +});