Adds in authentication system and overhauls the navigation bar to be built dynamically.

This commit is contained in:
Cameron Redmore 2025-04-24 21:35:52 +01:00
parent 7e98b5345d
commit 28c054de22
21 changed files with 1531 additions and 56 deletions

View file

@ -16,22 +16,29 @@
"@google/genai": "^0.9.0", "@google/genai": "^0.9.0",
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
"@quasar/extras": "^1.16.4", "@quasar/extras": "^1.16.4",
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/server": "^13.1.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"express-session": "^1.18.1",
"mailparser": "^3.7.2", "mailparser": "^3.7.2",
"marked": "^15.0.9", "marked": "^15.0.9",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"node-imap": "^0.9.6", "node-imap": "^0.9.6",
"pdfkit": "^0.17.0", "pdfkit": "^0.17.0",
"pdfmake": "^0.2.18", "pdfmake": "^0.2.18",
"pinia": "^3.0.2",
"quasar": "^2.16.0", "quasar": "^2.16.0",
"uuid": "^11.1.0",
"vue": "^3.4.18", "vue": "^3.4.18",
"vue-router": "^4.0.0" "vue-router": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@quasar/app-vite": "^2.1.0", "@quasar/app-vite": "^2.1.0",
"@types/express-session": "^1.18.1",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"prisma": "^6.6.0" "prisma": "^6.6.0"

281
pnpm-lock.yaml generated
View file

@ -17,6 +17,12 @@ importers:
'@quasar/extras': '@quasar/extras':
specifier: ^1.16.4 specifier: ^1.16.4
version: 1.16.17 version: 1.16.17
'@simplewebauthn/browser':
specifier: ^13.1.0
version: 13.1.0
'@simplewebauthn/server':
specifier: ^13.1.1
version: 13.1.1
axios: axios:
specifier: ^1.8.4 specifier: ^1.8.4
version: 1.8.4 version: 1.8.4
@ -29,6 +35,9 @@ importers:
dotenv: dotenv:
specifier: ^16.5.0 specifier: ^16.5.0
version: 16.5.0 version: 16.5.0
express-session:
specifier: ^1.18.1
version: 1.18.1
mailparser: mailparser:
specifier: ^3.7.2 specifier: ^3.7.2
version: 3.7.2 version: 3.7.2
@ -47,9 +56,15 @@ importers:
pdfmake: pdfmake:
specifier: ^0.2.18 specifier: ^0.2.18
version: 0.2.18 version: 0.2.18
pinia:
specifier: ^3.0.2
version: 3.0.2(vue@3.5.13)
quasar: quasar:
specifier: ^2.16.0 specifier: ^2.16.0
version: 2.18.1 version: 2.18.1
uuid:
specifier: ^11.1.0
version: 11.1.0
vue: vue:
specifier: ^3.4.18 specifier: ^3.4.18
version: 3.5.13 version: 3.5.13
@ -59,7 +74,13 @@ importers:
devDependencies: devDependencies:
'@quasar/app-vite': '@quasar/app-vite':
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.2.0(@types/node@22.14.1)(quasar@2.18.1)(rollup@4.40.0)(terser@5.39.0)(vue-router@4.5.0(vue@3.5.13))(vue@3.5.13) version: 2.2.0(@types/node@22.14.1)(pinia@3.0.2(vue@3.5.13))(quasar@2.18.1)(rollup@4.40.0)(terser@5.39.0)(vue-router@4.5.0(vue@3.5.13))(vue@3.5.13)
'@types/express-session':
specifier: ^1.18.1
version: 1.18.1
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
autoprefixer: autoprefixer:
specifier: ^10.4.2 specifier: ^10.4.2
version: 10.4.21(postcss@8.5.3) version: 10.4.21(postcss@8.5.3)
@ -258,6 +279,9 @@ packages:
resolution: {integrity: sha512-FD2RizYGInsvfjeaN6O+wQGpRnGVglS1XWrGQr8K7D04AfMmvPodDSw94U9KyFtsVLzWH9kmlPyFM+G4jbmkqg==} resolution: {integrity: sha512-FD2RizYGInsvfjeaN6O+wQGpRnGVglS1XWrGQr8K7D04AfMmvPodDSw94U9KyFtsVLzWH9kmlPyFM+G4jbmkqg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@inquirer/figures@1.0.11': '@inquirer/figures@1.0.11':
resolution: {integrity: sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==} resolution: {integrity: sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -287,6 +311,24 @@ packages:
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@peculiar/asn1-android@2.3.16':
resolution: {integrity: sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw==}
'@peculiar/asn1-ecc@2.3.15':
resolution: {integrity: sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==}
'@peculiar/asn1-rsa@2.3.15':
resolution: {integrity: sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==}
'@peculiar/asn1-schema@2.3.15':
resolution: {integrity: sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==}
'@peculiar/asn1-x509@2.3.15':
resolution: {integrity: sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -472,6 +514,13 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@simplewebauthn/browser@13.1.0':
resolution: {integrity: sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==}
'@simplewebauthn/server@13.1.1':
resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==}
engines: {node: '>=20.0.0'}
'@swc/helpers@0.5.17': '@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
@ -496,6 +545,9 @@ packages:
'@types/express-serve-static-core@4.19.6': '@types/express-serve-static-core@4.19.6':
resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==}
'@types/express-session@1.18.1':
resolution: {integrity: sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg==}
'@types/express@4.17.21': '@types/express@4.17.21':
resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==}
@ -532,6 +584,9 @@ packages:
'@types/serve-static@1.15.7': '@types/serve-static@1.15.7':
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@vitejs/plugin-vue@5.2.3': '@vitejs/plugin-vue@5.2.3':
resolution: {integrity: sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==} resolution: {integrity: sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@ -554,6 +609,15 @@ packages:
'@vue/devtools-api@6.6.4': '@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.5':
resolution: {integrity: sha512-HYV3tJGARROq5nlVMJh5KKHk7GU8Au3IrrmNNqr978m0edxgpHgYPDoNUGrvEgIbObz09SQezFR3A1EVmB5WZg==}
'@vue/devtools-kit@7.7.5':
resolution: {integrity: sha512-S9VAVJYVAe4RPx2JZb9ZTEi0lqTySz2CBeF0wHT5D3dkTLnT9yMMGegKNl4b2EIELwLSkcI9bl2qp0/jW+upqA==}
'@vue/devtools-shared@7.7.5':
resolution: {integrity: sha512-QBjG72RfpM0DKtpns2RZOxBltO226kOAls9e4Lri6YxS2gWTgL0H+wj1R2K76lxxIeOrqo4+2Ty6RQnzv+WSTQ==}
'@vue/reactivity@3.5.13': '@vue/reactivity@3.5.13':
resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==} resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==}
@ -623,6 +687,10 @@ packages:
array-flatten@1.1.1: array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
asn1js@3.0.6:
resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==}
engines: {node: '>=12.0.0'}
async@3.2.6: async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
@ -671,6 +739,9 @@ packages:
bindings@1.5.0: bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
birpc@2.3.0:
resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==}
bl@4.1.0: bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@ -835,10 +906,21 @@ packages:
cookie-signature@1.0.6: cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
cookie@0.7.1: cookie@0.7.1:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
core-util-is@1.0.3: core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@ -1068,6 +1150,10 @@ packages:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'} engines: {node: '>=6'}
express-session@1.18.1:
resolution: {integrity: sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==}
engines: {node: '>= 0.8.0'}
express@4.21.2: express@4.21.2:
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
engines: {node: '>= 0.10.0'} engines: {node: '>= 0.10.0'}
@ -1230,6 +1316,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
html-minifier-terser@7.2.0: html-minifier-terser@7.2.0:
resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==}
engines: {node: ^14.13.1 || >=16.0.0} engines: {node: ^14.13.1 || >=16.0.0}
@ -1341,6 +1430,10 @@ packages:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
is-wsl@2.2.0: is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1492,6 +1585,9 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp-classic@0.5.3: mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
@ -1653,6 +1749,9 @@ packages:
peberminta@0.9.0: peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -1664,6 +1763,15 @@ packages:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'} engines: {node: '>=12'}
pinia@3.0.2:
resolution: {integrity: sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pkg-types@1.3.1: pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@ -1713,6 +1821,13 @@ packages:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'} engines: {node: '>=6'}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
qs@6.13.0: qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@ -1721,6 +1836,10 @@ packages:
resolution: {integrity: sha512-db/P64Mzpt1uXJ0MapaG+IYJQ9hHDb5KtTCoszwC78DR7sA+Uoj7nBW2EytwYykIExEmqavOvKrdasTvqhkgEg==} resolution: {integrity: sha512-db/P64Mzpt1uXJ0MapaG+IYJQ9hHDb5KtTCoszwC78DR7sA+Uoj7nBW2EytwYykIExEmqavOvKrdasTvqhkgEg==}
engines: {node: '>= 10.18.1', npm: '>= 6.13.4', yarn: '>= 1.21.1'} engines: {node: '>= 10.18.1', npm: '>= 6.13.4', yarn: '>= 1.21.1'}
random-bytes@1.0.0:
resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
engines: {node: '>= 0.8'}
randombytes@2.1.0: randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@ -1773,6 +1892,9 @@ packages:
restructure@3.0.2: restructure@3.0.2:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup-plugin-visualizer@5.14.0: rollup-plugin-visualizer@5.14.0:
resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==} resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2036,6 +2158,10 @@ packages:
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
stack-trace@1.0.0-pre2: stack-trace@1.0.0-pre2:
resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -2073,6 +2199,10 @@ packages:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
superjson@2.2.2:
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
engines: {node: '>=16'}
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2165,6 +2295,10 @@ packages:
ufo@1.6.1: ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
uid-safe@2.1.5:
resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==}
engines: {node: '>= 0.8'}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -2198,6 +2332,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
@ -2475,6 +2613,8 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@hexagon/base64@1.1.28': {}
'@inquirer/figures@1.0.11': {} '@inquirer/figures@1.0.11': {}
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
@ -2508,6 +2648,41 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@levischuck/tiny-cbor@0.2.11': {}
'@peculiar/asn1-android@2.3.16':
dependencies:
'@peculiar/asn1-schema': 2.3.15
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-ecc@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-rsa@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-schema@2.3.15':
dependencies:
asn1js: 3.0.6
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509@2.3.15':
dependencies:
'@peculiar/asn1-schema': 2.3.15
asn1js: 3.0.6
pvtsutils: 1.3.6
tslib: 2.8.1
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@ -2543,7 +2718,7 @@ snapshots:
dependencies: dependencies:
'@prisma/debug': 6.6.0 '@prisma/debug': 6.6.0
'@quasar/app-vite@2.2.0(@types/node@22.14.1)(quasar@2.18.1)(rollup@4.40.0)(terser@5.39.0)(vue-router@4.5.0(vue@3.5.13))(vue@3.5.13)': '@quasar/app-vite@2.2.0(@types/node@22.14.1)(pinia@3.0.2(vue@3.5.13))(quasar@2.18.1)(rollup@4.40.0)(terser@5.39.0)(vue-router@4.5.0(vue@3.5.13))(vue@3.5.13)':
dependencies: dependencies:
'@quasar/render-ssr-error': 1.0.3 '@quasar/render-ssr-error': 1.0.3
'@quasar/ssl-certificate': 1.0.0 '@quasar/ssl-certificate': 1.0.0
@ -2585,6 +2760,8 @@ snapshots:
vue: 3.5.13 vue: 3.5.13
vue-router: 4.5.0(vue@3.5.13) vue-router: 4.5.0(vue@3.5.13)
webpack-merge: 6.0.1 webpack-merge: 6.0.1
optionalDependencies:
pinia: 3.0.2(vue@3.5.13)
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
- jiti - jiti
@ -2683,6 +2860,18 @@ snapshots:
domhandler: 5.0.3 domhandler: 5.0.3
selderee: 0.11.0 selderee: 0.11.0
'@simplewebauthn/browser@13.1.0': {}
'@simplewebauthn/server@13.1.1':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
'@peculiar/asn1-android': 2.3.16
'@peculiar/asn1-ecc': 2.3.15
'@peculiar/asn1-rsa': 2.3.15
'@peculiar/asn1-schema': 2.3.15
'@peculiar/asn1-x509': 2.3.15
'@swc/helpers@0.5.17': '@swc/helpers@0.5.17':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -2716,6 +2905,10 @@ snapshots:
'@types/range-parser': 1.2.7 '@types/range-parser': 1.2.7
'@types/send': 0.17.4 '@types/send': 0.17.4
'@types/express-session@1.18.1':
dependencies:
'@types/express': 4.17.21
'@types/express@4.17.21': '@types/express@4.17.21':
dependencies: dependencies:
'@types/body-parser': 1.19.5 '@types/body-parser': 1.19.5
@ -2758,6 +2951,8 @@ snapshots:
'@types/node': 22.14.1 '@types/node': 22.14.1
'@types/send': 0.17.4 '@types/send': 0.17.4
'@types/uuid@10.0.0': {}
'@vitejs/plugin-vue@5.2.3(vite@6.3.2(@types/node@22.14.1)(sass-embedded@1.87.0)(terser@5.39.0))(vue@3.5.13)': '@vitejs/plugin-vue@5.2.3(vite@6.3.2(@types/node@22.14.1)(sass-embedded@1.87.0)(terser@5.39.0))(vue@3.5.13)':
dependencies: dependencies:
vite: 6.3.2(@types/node@22.14.1)(sass-embedded@1.87.0)(terser@5.39.0) vite: 6.3.2(@types/node@22.14.1)(sass-embedded@1.87.0)(terser@5.39.0)
@ -2795,6 +2990,24 @@ snapshots:
'@vue/devtools-api@6.6.4': {} '@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.5':
dependencies:
'@vue/devtools-kit': 7.7.5
'@vue/devtools-kit@7.7.5':
dependencies:
'@vue/devtools-shared': 7.7.5
birpc: 2.3.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.2
'@vue/devtools-shared@7.7.5':
dependencies:
rfdc: 1.4.1
'@vue/reactivity@3.5.13': '@vue/reactivity@3.5.13':
dependencies: dependencies:
'@vue/shared': 3.5.13 '@vue/shared': 3.5.13
@ -2873,6 +3086,12 @@ snapshots:
array-flatten@1.1.1: {} array-flatten@1.1.1: {}
asn1js@3.0.6:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.3
tslib: 2.8.1
async@3.2.6: {} async@3.2.6: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
@ -2921,6 +3140,8 @@ snapshots:
dependencies: dependencies:
file-uri-to-path: 1.0.0 file-uri-to-path: 1.0.0
birpc@2.3.0: {}
bl@4.1.0: bl@4.1.0:
dependencies: dependencies:
buffer: 5.7.1 buffer: 5.7.1
@ -3112,8 +3333,16 @@ snapshots:
cookie-signature@1.0.6: {} cookie-signature@1.0.6: {}
cookie-signature@1.0.7: {}
cookie@0.7.1: {} cookie@0.7.1: {}
cookie@0.7.2: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
crc-32@1.2.2: {} crc-32@1.2.2: {}
@ -3330,6 +3559,19 @@ snapshots:
expand-template@2.0.3: {} expand-template@2.0.3: {}
express-session@1.18.1:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.7
debug: 2.6.9
depd: 2.0.0
on-headers: 1.0.2
parseurl: 1.3.3
safe-buffer: 5.2.1
uid-safe: 2.1.5
transitivePeerDependencies:
- supports-color
express@4.21.2: express@4.21.2:
dependencies: dependencies:
accepts: 1.3.8 accepts: 1.3.8
@ -3548,6 +3790,8 @@ snapshots:
he@1.2.0: {} he@1.2.0: {}
hookable@5.5.3: {}
html-minifier-terser@7.2.0: html-minifier-terser@7.2.0:
dependencies: dependencies:
camel-case: 4.1.2 camel-case: 4.1.2
@ -3670,6 +3914,8 @@ snapshots:
is-unicode-supported@0.1.0: {} is-unicode-supported@0.1.0: {}
is-what@4.1.16: {}
is-wsl@2.2.0: is-wsl@2.2.0:
dependencies: dependencies:
is-docker: 2.2.1 is-docker: 2.2.1
@ -3817,6 +4063,8 @@ snapshots:
minipass@7.1.2: {} minipass@7.1.2: {}
mitt@3.0.1: {}
mkdirp-classic@0.5.3: {} mkdirp-classic@0.5.3: {}
mlly@1.7.4: mlly@1.7.4:
@ -3971,12 +4219,19 @@ snapshots:
peberminta@0.9.0: {} peberminta@0.9.0: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
picomatch@4.0.2: {} picomatch@4.0.2: {}
pinia@3.0.2(vue@3.5.13):
dependencies:
'@vue/devtools-api': 7.7.5
vue: 3.5.13
pkg-types@1.3.1: pkg-types@1.3.1:
dependencies: dependencies:
confbox: 0.1.8 confbox: 0.1.8
@ -4035,12 +4290,20 @@ snapshots:
punycode.js@2.3.1: {} punycode.js@2.3.1: {}
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.3: {}
qs@6.13.0: qs@6.13.0:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
quasar@2.18.1: {} quasar@2.18.1: {}
random-bytes@1.0.0: {}
randombytes@2.1.0: randombytes@2.1.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@ -4113,6 +4376,8 @@ snapshots:
restructure@3.0.2: {} restructure@3.0.2: {}
rfdc@1.4.1: {}
rollup-plugin-visualizer@5.14.0(rollup@4.40.0): rollup-plugin-visualizer@5.14.0(rollup@4.40.0):
dependencies: dependencies:
open: 8.4.2 open: 8.4.2
@ -4381,6 +4646,8 @@ snapshots:
source-map@0.7.4: {} source-map@0.7.4: {}
speakingurl@14.0.1: {}
stack-trace@1.0.0-pre2: {} stack-trace@1.0.0-pre2: {}
statuses@2.0.1: {} statuses@2.0.1: {}
@ -4422,6 +4689,10 @@ snapshots:
strip-json-comments@2.0.1: {} strip-json-comments@2.0.1: {}
superjson@2.2.2:
dependencies:
copy-anything: 3.0.5
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
@ -4510,6 +4781,10 @@ snapshots:
ufo@1.6.1: {} ufo@1.6.1: {}
uid-safe@2.1.5:
dependencies:
random-bytes: 1.0.0
undici-types@6.21.0: {} undici-types@6.21.0: {}
unicode-properties@1.4.1: unicode-properties@1.4.1:
@ -4540,6 +4815,8 @@ snapshots:
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
uuid@11.1.0: {}
uuid@8.3.2: {} uuid@8.3.2: {}
uuid@9.0.1: {} uuid@9.0.1: {}

View file

@ -1,5 +1,7 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- '@prisma/client' - '@prisma/client'
- '@prisma/engines'
- better-sqlite3 - better-sqlite3
- esbuild - esbuild
- prisma
- sqlite3 - sqlite3

View file

@ -99,3 +99,27 @@ model Setting {
@@map("settings") // Map to the 'settings' table @@map("settings") // Map to the 'settings' table
} }
// Added for WebAuthn
model User {
id String @id @default(uuid())
username String @unique
authenticators Authenticator[]
@@map("users")
}
model Authenticator {
id String @id @default(uuid())
credentialID String @unique @map("credential_id") // Base64URL encoded
credentialPublicKey Bytes @map("credential_public_key")
counter BigInt
credentialDeviceType String @map("credential_device_type") // 'singleDevice' or 'multiDevice'
credentialBackedUp Boolean @map("credential_backed_up")
transports String? // Comma-separated list like "internal,hybrid"
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("authenticators")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

BIN
public/stylepoint.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

390
src-ssr/routes/auth.js Normal file
View file

@ -0,0 +1,390 @@
// src-ssr/routes/auth.js
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
import prisma from '../database.js';
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
const router = express.Router();
// Helper function to get user authenticators
async function getUserAuthenticators(userId) {
return prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true,
credentialPublicKey: true,
counter: true,
transports: true,
},
});
}
// Helper function to get a user by username
async function getUserByUsername(username) {
return prisma.user.findUnique({ where: { username } });
}
// Helper function to get a user by ID
async function getUserById(id) {
return prisma.user.findUnique({ where: { id } });
}
// Helper function to get an authenticator by credential ID
async function getAuthenticatorByCredentialID(credentialID) {
return prisma.authenticator.findUnique({ where: { credentialID } });
}
// Generate Registration Options
router.post('/generate-registration-options', async (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ error: 'Username is required' });
}
try {
let user = await getUserByUsername(username);
// If user doesn't exist, create one
if (!user) {
user = await prisma.user.create({
data: { username },
});
}
const userAuthenticators = await getUserAuthenticators(user.id);
if(userAuthenticators.length > 0) {
//The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id) {
return res.status(403).json({ error: 'Invalid registration attempt.' });
}
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.username,
// Don't prompt users for additional authenticators if they've already registered some
excludeCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID, // Use isoBase64URL helper
type: 'public-key',
// Optional: Specify transports if you know them
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
authenticatorSelection: {
// Defaults
residentKey: 'required',
userVerification: 'preferred',
},
// Strong advice: Always require attestation for registration
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
});
// Store the challenge
challengeStore.set(user.id, options.challenge);
req.session.userId = user.id; // Temporarily store userId in session for verification step
res.json(options);
} catch (error) {
console.error('Registration options error:', error);
res.status(500).json({ error: 'Failed to generate registration options' });
}
});
// Verify Registration
router.post('/verify-registration', async (req, res) => {
const { registrationResponse } = req.body;
const userId = req.session.userId; // Retrieve userId stored during options generation
if (!userId) {
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
}
const expectedChallenge = challengeStore.get(userId);
if (!expectedChallenge) {
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try {
const user = await getUserById(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false, // Adjust based on your requirements
});
const { verified, registrationInfo } = verification;
console.log(verification);
if (verified && registrationInfo) {
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
const credentialID = credential.id;
const credentialPublicKey = credential.publicKey;
const counter = credential.counter;
const transports = credential.transports || []; // Use empty array if transports are not provided
// Check if authenticator with this ID already exists
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
if (existingAuthenticator) {
return res.status(409).json({ error: 'Authenticator already registered' });
}
// Save the authenticator
await prisma.authenticator.create({
data: {
credentialID, // Store as Base64URL string
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
counter: BigInt(counter), // Store as BigInt
credentialDeviceType,
credentialBackedUp,
transports: transports.join(','), // Store transports as comma-separated string
userId: user.id,
},
});
// Clear the challenge and temporary userId
challengeStore.delete(userId);
delete req.session.userId;
// Log the user in by setting the final session userId
req.session.loggedInUserId = user.id;
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Registration verification failed' });
}
} catch (error) {
console.error('Registration verification error:', error);
challengeStore.delete(userId); // Clean up challenge on error
delete req.session.userId;
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
}
});
// Generate Authentication Options
router.post('/generate-authentication-options', async (req, res) => {
const { username } = req.body;
try {
let user;
if (username) {
user = await getUserByUsername(username);
} else if (req.session.loggedInUserId) {
// If already logged in, allow re-authentication (e.g., for step-up)
user = await getUserById(req.session.loggedInUserId);
}
if (!user) {
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
allowCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
userVerification: 'preferred',
});
// Store the challenge associated with the user ID for verification
challengeStore.set(user.id, options.challenge);
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
res.json(options);
} catch (error) {
console.error('Authentication options error:', error);
res.status(500).json({ error: 'Failed to generate authentication options' });
}
});
// Verify Authentication
router.post('/verify-authentication', async (req, res) => {
const { authenticationResponse } = req.body;
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
if (!challengeUserId) {
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
}
const expectedChallenge = challengeStore.get(challengeUserId);
if (!expectedChallenge) {
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try {
const user = await getUserById(challengeUserId);
if (!user) {
return res.status(404).json({ error: 'User associated with challenge not found' });
}
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
if (!authenticator) {
return res.status(404).json({ error: 'Authenticator not found' });
}
// Ensure the authenticator belongs to the user attempting to log in
if (authenticator.userId !== user.id) {
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
}
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: authenticator.credentialID,
publicKey: authenticator.credentialPublicKey,
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
},
requireUserVerification: false, // Enforce user verification
});
const { verified, authenticationInfo } = verification;
if (verified) {
// Update the authenticator counter
await prisma.authenticator.update({
where: { credentialID: authenticator.credentialID },
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
});
// Clear the challenge and associated user ID
challengeStore.delete(challengeUserId);
delete req.session.challengeUserId;
// Log the user in
req.session.loggedInUserId = user.id;
res.json({ verified: true, user: { id: user.id, username: user.username } });
} else {
res.status(400).json({ error: 'Authentication verification failed' });
}
} catch (error) {
console.error('Authentication verification error:', error);
challengeStore.delete(challengeUserId); // Clean up challenge on error
delete req.session.challengeUserId;
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
}
});
// GET Passkeys for Logged-in User
router.get('/passkeys', async (req, res) => {
if (!req.session.loggedInUserId) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const userId = req.session.loggedInUserId;
const authenticators = await prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true, // Already Base64URL string
// Add other fields if needed, e.g., createdAt if you add it to the schema
// createdAt: true,
},
});
// No need to convert credentialID here as it's stored as Base64URL string
res.json(authenticators);
} catch (error) {
console.error('Error fetching passkeys:', error);
res.status(500).json({ error: 'Failed to fetch passkeys' });
}
});
// DELETE Passkey
router.delete('/passkeys/:credentialID', async (req, res) => {
if (!req.session.loggedInUserId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { credentialID } = req.params; // This is already a Base64URL string from the client
if (!credentialID) {
return res.status(400).json({ error: 'Credential ID is required' });
}
try {
const userId = req.session.loggedInUserId;
// Find the authenticator first to ensure it belongs to the logged-in user
const authenticator = await prisma.authenticator.findUnique({
where: { credentialID: credentialID }, // Use the Base64URL string directly
});
if (!authenticator) {
return res.status(404).json({ error: 'Passkey not found' });
}
// Security check: Ensure the passkey belongs to the user trying to delete it
if (authenticator.userId !== userId) {
return res.status(403).json({ error: 'Permission denied' });
}
// Delete the authenticator
await prisma.authenticator.delete({
where: { credentialID: credentialID },
});
res.json({ message: 'Passkey deleted successfully' });
} catch (error) {
console.error('Error deleting passkey:', error);
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
if (error.code === 'P2025') { // Prisma code for record not found on delete/update
return res.status(404).json({ error: 'Passkey not found' });
}
res.status(500).json({ error: 'Failed to delete passkey' });
}
});
// Check Authentication Status
router.get('/status', (req, res) => {
if (req.session.loggedInUserId) {
return res.json({ status: 'authenticated' });
}
res.json({ status: 'unauthenticated' });
});
// Logout
router.post('/logout', (req, res) => {
req.session.destroy(err => {
if (err) {
console.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
res.json({ message: 'Logged out successfully' });
});
});
export default router;

View file

@ -11,6 +11,8 @@
*/ */
import express from 'express' import express from 'express'
import compression from 'compression' import compression from 'compression'
import session from 'express-session'; // Added for session management
import { v4 as uuidv4 } from 'uuid'; // Added for generating session IDs
import { import {
defineSsrCreate, defineSsrCreate,
defineSsrListen, defineSsrListen,
@ -21,9 +23,18 @@ import {
import prisma from './database.js'; // Import the prisma client instance import prisma from './database.js'; // Import the prisma client instance
import apiRoutes from './routes/api.js'; import apiRoutes from './routes/api.js';
import authRoutes from './routes/auth.js'; // Added for WebAuthn routes
import cron from 'node-cron'; import cron from 'node-cron';
import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js'; import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
// Define Relying Party details (Update with your actual details)
export const rpID = process.env.NODE_ENV === 'production' ? 'your-production-domain.com' : 'localhost';
export const rpName = 'StylePoint';
export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9100`;
// In-memory store for challenges (Replace with a persistent store in production)
export const challengeStore = new Map();
/** /**
* Create your webserver and return its instance. * Create your webserver and return its instance.
* If needed, prepare your webserver to receive * If needed, prepare your webserver to receive
@ -34,6 +45,19 @@ import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
export const create = defineSsrCreate((/* { ... } */) => { export const create = defineSsrCreate((/* { ... } */) => {
const app = express() const app = express()
// Session middleware configuration
app.use(session({
genid: (req) => uuidv4(), // Use UUIDs for session IDs
secret: process.env.SESSION_SECRET || 'a-very-strong-secret-key', // Use an environment variable for the secret
resave: false,
saveUninitialized: true,
cookie: {
secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 // 1 day
}
}));
// Initialize the database (now synchronous) // Initialize the database (now synchronous)
try { try {
console.log('Prisma Client is ready.'); // Log Prisma readiness console.log('Prisma Client is ready.'); // Log Prisma readiness
@ -72,6 +96,7 @@ export const create = defineSsrCreate((/* { ... } */) => {
// Add API routes // Add API routes
app.use('/api', apiRoutes); app.use('/api', apiRoutes);
app.use('/auth', authRoutes); // Added WebAuthn auth routes
// place here any middlewares that // place here any middlewares that
// absolutely need to run before anything else // absolutely need to run before anything else

View file

@ -16,71 +16,42 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item clickable v-ripple :to="{ name: 'formList' }" exact> <!-- Dynamic Navigation Items -->
<q-tooltip anchor="center right" self="center left" >
<span>Forms</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="list_alt" />
</q-item-section>
<q-item-section>
<q-item-label>Forms</q-item-label>
<q-item-label caption>View existing forms</q-item-label>
</q-item-section>
</q-item>
<q-item <q-item
v-for="item in navItems"
:key="item.name"
clickable clickable
v-ripple v-ripple
:to="{ name: 'mantisSummaries' }" :to="{ name: item.name }"
exact exact
> >
<q-tooltip anchor="center right" self="center left" > <q-tooltip anchor="center right" self="center left" >
<span>Mantis Summaries</span> <span>{{ item.meta.title }}</span>
</q-tooltip> </q-tooltip>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="summarize" /> <q-icon :name="item.meta.icon" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>Mantis Summaries</q-item-label> <q-item-label>{{ item.meta.title }}</q-item-label>
<q-item-label caption>View daily summaries</q-item-label> <q-item-label caption>{{ item.meta.caption }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<!-- Logout Button (Conditional) -->
<q-item <q-item
v-if="authStore.isAuthenticated"
clickable clickable
v-ripple v-ripple
:to="{ name: 'emailSummaries' }" @click="logout"
exact
> >
<q-tooltip anchor="center right" self="center left" > <q-tooltip anchor="center right" self="center left" >
<span>Email Summaries</span> <span>Logout</span>
</q-tooltip> </q-tooltip>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="email" /> <q-icon name="logout" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>Email Summaries</q-item-label> <q-item-label>Logout</q-item-label>
<q-item-label caption>View email summaries</q-item-label>
</q-item-section>
</q-item>
<q-item
clickable
to="/settings" exact
>
<q-tooltip anchor="center right" self="center left" >
<span>Settings</span>
</q-tooltip>
<q-item-section
avatar
>
<q-icon name="settings" />
</q-item-section>
<q-item-section>
<q-item-label>Settings</q-item-label>
<q-item-label caption>Manage application settings</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
@ -94,11 +65,55 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import axios from 'axios'
import { ref, computed } from 'vue' // Import computed
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useAuthStore } from 'stores/auth'; // Import the auth store
import routes from '../router/routes'; // Import routes
const $q = useQuasar()
const leftDrawerOpen = ref(false) const leftDrawerOpen = ref(false)
const router = useRouter()
const authStore = useAuthStore(); // Use the auth store
// Get the child routes of the main layout
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
// Compute navigation items based on auth state and route meta
const navItems = computed(() => {
const isAuthenticated = authStore.isAuthenticated;
return mainLayoutRoutes.filter(route => {
const navGroup = route.meta?.navGroup;
if (!navGroup) return false; // Only include routes with navGroup defined
if (navGroup === 'always') return true;
if (navGroup === 'auth' && isAuthenticated) return true;
if (navGroup === 'noAuth' && !isAuthenticated) return true;
return false; // Exclude otherwise
});
});
function toggleLeftDrawer () { function toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value leftDrawerOpen.value = !leftDrawerOpen.value
} }
async function logout() {
try {
await axios.post('/auth/logout');
authStore.logout(); // Use the store action to update state
// No need to manually push, router guard should redirect
// router.push({ name: 'login' });
} catch (error) {
console.error('Logout failed:', error);
$q.notify({
color: 'negative',
message: 'Logout failed. Please try again.',
icon: 'report_problem'
});
}
}
</script> </script>

36
src/pages/LandingPage.vue Normal file
View file

@ -0,0 +1,36 @@
<template>
<q-page class="landing-page column items-center q-pa-md">
<div class="hero text-center q-pa-xl full-width">
<h1 class="text-h3 text-weight-bold text-primary q-mb-sm">Welcome to StylePoint</h1>
<p class="text-h6 text-grey-8 q-mb-lg">The all-in-one tool designed for StyleTech Developers.</p>
</div>
<div class="features q-mt-xl q-pa-md text-center" style="max-width: 800px; width: 100%;">
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">Features</h2>
<q-list bordered separator class="rounded-borders">
<q-item v-for="(feature, index) in features" :key="index" class="q-pa-md">
<q-item-section>
<q-item-label class="text-body1">{{ feature }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const currentYear = ref(new Date().getFullYear());
const features = ref([
'Auatomated Daily Reports',
'Deep Mantis Integration',
'Easy Authentication',
'And more..?'
]);
</script>

103
src/pages/LoginPage.vue Normal file
View file

@ -0,0 +1,103 @@
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<div class="text-h6">Login</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
@keyup.enter="handleLogin"
:hint="errorMessage ? errorMessage : ''"
:rules="[val => !!val || 'Username is required']"
/>
<q-btn
label="Login with Passkey"
color="primary"
class="full-width"
@click="handleLogin"
:loading="loading"
/>
<div v-if="errorMessage" class="text-negative q-mt-md">{{ errorMessage }}</div>
</q-card-section>
<q-card-actions align="center">
<q-btn flat label="Don't have an account? Register" to="/register" />
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { startAuthentication } from '@simplewebauthn/browser';
import axios from 'axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const username = ref('');
const loading = ref(false);
const errorMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
async function handleLogin() {
loading.value = true;
errorMessage.value = '';
try {
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-authentication-options', {
username: username.value || undefined, // Send username if provided
});
const options = optionsRes.data;
// 2. Start authentication ceremony in browser
const authResp = await startAuthentication(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/auth/verify-authentication', {
authenticationResponse: authResp,
});
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
} else {
errorMessage.value = 'Authentication failed.';
// Optionally update store state on failure
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = 'Authentication failed.';
}
} catch (error) {
console.error('Login error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during login.';
// Handle specific simplewebauthn errors if needed
if (error.name === 'NotAllowedError') {
errorMessage.value = 'Authentication ceremony was cancelled or timed out.';
} else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found')) {
errorMessage.value = 'User not found. Please check your username or register.';
} else if (error.response?.status === 404 && error.response?.data?.error?.includes('Authenticator not found')) {
errorMessage.value = 'No registered passkey found for this user or device. Try registering first.';
} else {
errorMessage.value = `Login failed: ${message}`;
}
// Optionally update store state on error
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = `Login failed: ${message}`;
} finally {
loading.value = false;
}
}
</script>

View file

@ -0,0 +1,275 @@
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">Passkey Management</div>
<div>
<q-btn
label="Identify Passkey"
color="secondary"
class="q-mx-md q-mt-md"
@click="handleIdentify"
:loading="identifyLoading"
:disable="identifyLoading || !isLoggedIn"
outline
/>
<q-btn
label="Register New Passkey"
color="primary"
class="q-mx-md q-mt-md"
@click="handleRegister"
:loading="registerLoading"
:disable="registerLoading || !isLoggedIn"
outline
/>
</div>
</div>
<!-- Passkey List Section -->
<q-card-section>
<h5>Your Registered Passkeys</h5>
<q-list bordered separator v-if="passkeys.length > 0 && !fetchLoading">
<q-item v-if="registerSuccessMessage || registerErrorMessage">
<div v-if="registerSuccessMessage" class="text-positive q-mt-md">{{ registerSuccessMessage }}</div>
<div v-if="registerErrorMessage" class="text-negative q-mt-md">{{ registerErrorMessage }}</div>
</q-item>
<q-item
v-for="passkey in passkeys"
:key="passkey.credentialID"
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
>
<q-item-section>
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
<q-item-label caption v-if="identifiedPasskeyId === passkey.credentialID">
Verified just now!
</q-item-label>
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
</q-item-section>
<q-item-section side class="row no-wrap items-center">
<!-- Delete Button -->
<q-btn
flat
dense
round
color="negative"
icon="delete"
@click="handleDelete(passkey.credentialID)"
:loading="deleteLoading === passkey.credentialID"
:disable="!!deleteLoading || !!identifyLoading"
/>
</q-item-section>
</q-item>
</q-list>
<div v-else-if="fetchLoading" class="q-mt-md">Loading passkeys...</div>
<div v-else class="q-mt-md">You have no passkeys registered yet.</div>
<div v-if="fetchErrorMessage" class="text-negative q-mt-md">{{ fetchErrorMessage }}</div>
<div v-if="deleteSuccessMessage" class="text-positive q-mt-md">{{ deleteSuccessMessage }}</div>
<div v-if="deleteErrorMessage" class="text-negative q-mt-md">{{ deleteErrorMessage }}</div>
<div v-if="identifyErrorMessage" class="text-negative q-mt-md">{{ identifyErrorMessage }}</div>
</q-card-section>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
import axios from 'axios';
import { useAuthStore } from 'stores/auth';
const registerLoading = ref(false);
const registerErrorMessage = ref('');
const registerSuccessMessage = ref('');
const fetchLoading = ref(false);
const fetchErrorMessage = ref('');
const deleteLoading = ref(null);
const deleteErrorMessage = ref('');
const deleteSuccessMessage = ref('');
const identifyLoading = ref(null); // Store the ID of the passkey being identified
const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username);
// Fetch existing passkeys
async function fetchPasskeys() {
if (!isLoggedIn.value) return;
fetchLoading.value = true;
fetchErrorMessage.value = '';
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
try {
const response = await axios.get('/auth/passkeys');
passkeys.value = response.data || [];
} catch (error) {
console.error('Error fetching passkeys:', error);
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
passkeys.value = []; // Clear passkeys on error
} finally {
fetchLoading.value = false;
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async () => {
let initialAuthError = '';
if (!authStore.isAuthenticated) {
await authStore.checkAuthStatus();
if (authStore.error) {
initialAuthError = `Authentication error: ${authStore.error}`;
}
}
if (!isLoggedIn.value) {
// Use register error message ref for consistency if login is required first
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
} else {
fetchPasskeys(); // Fetch passkeys if logged in
}
});
async function handleRegister() {
if (!isLoggedIn.value || !username.value) {
registerErrorMessage.value = 'User not authenticated.';
return;
}
registerLoading.value = true;
registerErrorMessage.value = '';
registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages
deleteErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try {
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', {
username: username.value, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified) {
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
} else {
registerErrorMessage.value = 'Passkey verification failed.';
}
} catch (error) {
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError') {
registerErrorMessage.value = 'Authenticator may already be registered.';
} else if (error.name === 'NotAllowedError') {
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
} else if (error.response?.status === 409) {
registerErrorMessage.value = 'This passkey seems to be registered already.';
} else {
registerErrorMessage.value = `Registration failed: ${message}`;
}
} finally {
registerLoading.value = false;
}
}
// Handle deleting a passkey
async function handleDelete(credentialID) {
if (!credentialID) return;
// Optional: Add a confirmation dialog here
// if (!confirm('Are you sure you want to delete this passkey?')) {
// return;
// }
deleteLoading.value = credentialID; // Set loading state for the specific button
deleteErrorMessage.value = '';
deleteSuccessMessage.value = '';
registerSuccessMessage.value = ''; // Clear other messages
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try {
await axios.delete(`/auth/passkeys/${credentialID}`);
deleteSuccessMessage.value = 'Passkey deleted successfully.';
fetchPasskeys(); // Refresh the list
} catch (error) {
console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
} finally {
deleteLoading.value = null; // Clear loading state
}
}
// Handle identifying a passkey
async function handleIdentify() {
if (!isLoggedIn.value) {
identifyErrorMessage.value = 'You must be logged in.';
return;
}
identifyLoading.value = true;
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key
// Clear other messages
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
try {
// 1. Get authentication options from the server
// We don't need to send username as the server should use the session
const optionsRes = await axios.post('/auth/generate-authentication-options', {}); // Send empty body
const options = optionsRes.data;
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
// 2. Start authentication ceremony in the browser
const authResp = await startAuthentication(options);
// 3. If successful, the response contains the ID of the key used
identifiedPasskeyId.value = authResp.id;
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
// Optional: Add a small delay before clearing the highlight
setTimeout(() => {
// Only clear if it's still the same identified key
if (identifiedPasskeyId.value === authResp.id) {
identifiedPasskeyId.value = null;
}
}, 5000); // Clear highlight after 5 seconds
} catch (error) {
console.error('Identification error:', error);
identifiedPasskeyId.value = null;
if (error.name === 'NotAllowedError') {
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
} else {
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
}
} finally {
identifyLoading.value = null; // Clear loading state
}
}
</script>

135
src/pages/RegisterPage.vue Normal file
View file

@ -0,0 +1,135 @@
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<!-- Update title based on login status from store -->
<div class="text-h6">{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
:rules="[val => !!val || 'Username is required']"
@keyup.enter="handleRegister"
:disable="isLoggedIn"
:hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''"
:readonly="isLoggedIn"
/>
<q-btn
:label="isLoggedIn ? 'Register New Passkey' : 'Register Passkey'"
color="primary"
class="full-width"
@click="handleRegister"
:loading="loading"
:disable="loading || (!username && !isLoggedIn)"
/>
<div v-if="successMessage" class="text-positive q-mt-md">{{ successMessage }}</div>
<div v-if="errorMessage" class="text-negative q-mt-md">{{ errorMessage }}</div>
</q-card-section>
<q-card-actions align="center">
<!-- Hide login link if already logged in based on store state -->
<q-btn v-if="!isLoggedIn" flat label="Already have an account? Login" to="/login" />
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'; // Import computed
import { useRouter } from 'vue-router';
import { startRegistration } from '@simplewebauthn/browser';
import axios from 'axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const loading = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = ref(''); // Local ref for username input
// Check auth status on component mount using the store action
onMounted(async () => {
if (!authStore.isAuthenticated) {
await authStore.checkAuthStatus();
if (authStore.error) {
errorMessage.value = authStore.error;
}
}
if (!isLoggedIn.value) {
username.value = ''; // Clear username if not logged in
} else {
username.value = authStore.user?.username || ''; // Use username from store if logged in
}
});
async function handleRegister() {
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
if (!currentUsername) {
errorMessage.value = 'Username is missing.';
return;
}
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
try {
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', {
username: currentUsername, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified) {
// Adjust success message based on login state
successMessage.value = isLoggedIn.value
? 'New passkey registered successfully!'
: 'Registration successful! Redirecting to login...';
if (!isLoggedIn.value) {
// Redirect to login page only if they weren't logged in
setTimeout(() => {
router.push('/login');
}, 2000);
} else {
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
}
} else {
errorMessage.value = 'Registration failed.';
}
} catch (error) {
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError') {
errorMessage.value = 'Authenticator already registered. Try logging in instead.';
} else if (error.name === 'NotAllowedError') {
errorMessage.value = 'Registration ceremony was cancelled or timed out.';
} else if (error.response?.status === 409) {
errorMessage.value = 'This passkey seems to be registered already.';
} else {
errorMessage.value = `Registration failed: ${message}`;
}
} finally {
loading.value = false;
}
}
</script>

View file

@ -1,6 +1,7 @@
import { defineRouter } from '#q-app/wrappers' import { defineRouter } from '#q-app/wrappers'
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router' import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
import routes from './routes' import routes from './routes'
import { useAuthStore } from 'stores/auth'; // Import the auth store
/* /*
* If not building with SSR mode, you can * If not building with SSR mode, you can
@ -11,7 +12,7 @@ import routes from './routes'
* with the Router instance. * with the Router instance.
*/ */
export default defineRouter(function (/* { store, ssrContext } */) { export default defineRouter(function ({ store /* { store, ssrContext } */ }) {
const createHistory = process.env.SERVER const createHistory = process.env.SERVER
? createMemoryHistory ? createMemoryHistory
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory) : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
@ -26,5 +27,45 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE) history: createHistory(process.env.VUE_ROUTER_BASE)
}) })
// Navigation Guard using Pinia store
Router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore(store); // Get store instance
// Ensure auth status is checked, especially on first load or refresh
// This check might be better placed in App.vue or a boot file
if (!authStore.user && !authStore.loading) { // Check only if user is not loaded and not already loading
try {
await authStore.checkAuthStatus();
} catch (e) {
console.error("Initial auth check failed", e);
// Decide how to handle initial check failure (e.g., proceed, redirect to error page)
}
}
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
const publicPages = ['/login', '/register'];
const isPublicPage = publicPages.includes(to.path);
const isAuthenticated = authStore.isAuthenticated; // Get status from store
console.log('Store Auth status:', isAuthenticated);
console.log('Navigating to:', to.path);
console.log('Requires auth:', requiresAuth);
console.log('Is public page:', isPublicPage);
if (requiresAuth && !isAuthenticated) {
// If route requires auth and user is not authenticated, redirect to login
console.log('Redirecting to login (requires auth, not authenticated)');
next('/login');
} else if (isPublicPage && isAuthenticated) {
// If user is authenticated and tries to access login/register, redirect to home
console.log('Redirecting to home (public page, authenticated)');
next('/');
} else {
// Otherwise, allow navigation
console.log('Allowing navigation');
next();
}
});
return Router return Router
}) })

View file

@ -3,15 +3,101 @@ const routes = [
path: '/', path: '/',
component: () => import('layouts/MainLayout.vue'), component: () => import('layouts/MainLayout.vue'),
children: [ children: [
{ path: '', name: 'home', component: () => import('pages/FormListPage.vue') }, {
{ path: 'forms', name: 'formList', component: () => import('pages/FormListPage.vue') }, path: '',
{ path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue') }, name: 'home',
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true }, component: () => import('pages/LandingPage.vue'),
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true }, meta: { requiresAuth: false } // Keep home accessible, but don't show in nav
{ path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true }, },
{ path: 'mantis-summaries', name: 'mantisSummaries', component: () => import('pages/MantisSummariesPage.vue') }, {
{ path: 'email-summaries', name: 'emailSummaries', component: () => import('pages/EmailSummariesPage.vue') }, path: '/login',
{ path: 'settings', name: 'settings', component: () => import('pages/SettingsPage.vue') } name: 'login',
component: () => import('pages/LoginPage.vue'),
meta: {
requiresAuth: false,
navGroup: 'noAuth', // Show only when logged out
icon: 'login',
title: 'Login',
caption: 'Access your account'
}
},
{
path: '/register',
name: 'register',
component: () => import('pages/RegisterPage.vue'),
meta: {
requiresAuth: false,
navGroup: 'noAuth', // Show only when logged out
icon: 'person_add',
title: 'Register',
caption: 'Create an account'
}
},
// Add a new route specifically for managing passkeys when logged in
{
path: '/passkeys',
name: 'passkeys',
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'
}
},
{
path: 'forms',
name: 'formList',
component: () => import('pages/FormListPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'list_alt',
title: 'Forms',
caption: 'View existing forms'
}
},
{ path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue'), meta: { requiresAuth: true } }, // Not in nav
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{ path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{
path: 'mantis-summaries',
name: 'mantisSummaries',
component: () => import('pages/MantisSummariesPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'summarize',
title: 'Mantis Summaries',
caption: 'View daily summaries'
}
},
{
path: 'email-summaries',
name: 'emailSummaries',
component: () => import('pages/EmailSummariesPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'email',
title: 'Email Summaries',
caption: 'View email summaries'
}
},
{
path: 'settings',
name: 'settings',
component: () => import('pages/SettingsPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'settings',
title: 'Settings',
caption: 'Manage application settings'
}
}
] ]
}, },

39
src/stores/auth.js Normal file
View file

@ -0,0 +1,39 @@
import { defineStore } from 'pinia'
import axios from 'axios';
export const useAuthStore = defineStore('auth', {
state: () => ({
isAuthenticated: false,
user: null,
loading: false, // Optional: track loading state
error: null, // Optional: track errors
}),
actions: {
async checkAuthStatus() {
this.loading = true;
this.error = null;
try {
const res = await axios.get('/auth/check-auth');
if (res.data.isAuthenticated) {
this.isAuthenticated = true;
this.user = res.data.user;
} else {
this.isAuthenticated = false;
this.user = null;
}
} catch (error) {
console.error('Failed to check authentication status:', error);
this.error = 'Could not verify login status.';
this.isAuthenticated = false;
this.user = null;
} finally {
this.loading = false;
}
},
// Action to manually set user as logged out (e.g., after logout)
logout() {
this.isAuthenticated = false;
this.user = null;
}
},
});

20
src/stores/index.js Normal file
View file

@ -0,0 +1,20 @@
import { defineStore } from '#q-app/wrappers'
import { createPinia } from 'pinia'
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) => {
const pinia = createPinia()
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia
})