Adds in authentication system and overhauls the navigation bar to be built dynamically.
|
@ -16,22 +16,29 @@
|
|||
"@google/genai": "^0.9.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@simplewebauthn/server": "^13.1.1",
|
||||
"axios": "^1.8.4",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"express-session": "^1.18.1",
|
||||
"mailparser": "^3.7.2",
|
||||
"marked": "^15.0.9",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-imap": "^0.9.6",
|
||||
"pdfkit": "^0.17.0",
|
||||
"pdfmake": "^0.2.18",
|
||||
"pinia": "^3.0.2",
|
||||
"quasar": "^2.16.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.4.18",
|
||||
"vue-router": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/app-vite": "^2.1.0",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"postcss": "^8.4.14",
|
||||
"prisma": "^6.6.0"
|
||||
|
|
281
pnpm-lock.yaml
generated
|
@ -17,6 +17,12 @@ importers:
|
|||
'@quasar/extras':
|
||||
specifier: ^1.16.4
|
||||
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:
|
||||
specifier: ^1.8.4
|
||||
version: 1.8.4
|
||||
|
@ -29,6 +35,9 @@ importers:
|
|||
dotenv:
|
||||
specifier: ^16.5.0
|
||||
version: 16.5.0
|
||||
express-session:
|
||||
specifier: ^1.18.1
|
||||
version: 1.18.1
|
||||
mailparser:
|
||||
specifier: ^3.7.2
|
||||
version: 3.7.2
|
||||
|
@ -47,9 +56,15 @@ importers:
|
|||
pdfmake:
|
||||
specifier: ^0.2.18
|
||||
version: 0.2.18
|
||||
pinia:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(vue@3.5.13)
|
||||
quasar:
|
||||
specifier: ^2.16.0
|
||||
version: 2.18.1
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
vue:
|
||||
specifier: ^3.4.18
|
||||
version: 3.5.13
|
||||
|
@ -59,7 +74,13 @@ importers:
|
|||
devDependencies:
|
||||
'@quasar/app-vite':
|
||||
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:
|
||||
specifier: ^10.4.2
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
|
@ -258,6 +279,9 @@ packages:
|
|||
resolution: {integrity: sha512-FD2RizYGInsvfjeaN6O+wQGpRnGVglS1XWrGQr8K7D04AfMmvPodDSw94U9KyFtsVLzWH9kmlPyFM+G4jbmkqg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==}
|
||||
engines: {node: '>=18'}
|
||||
|
@ -287,6 +311,24 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.25':
|
||||
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':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
@ -472,6 +514,13 @@ packages:
|
|||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
||||
|
||||
|
@ -496,6 +545,9 @@ packages:
|
|||
'@types/express-serve-static-core@4.19.6':
|
||||
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':
|
||||
resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==}
|
||||
|
||||
|
@ -532,6 +584,9 @@ packages:
|
|||
'@types/serve-static@1.15.7':
|
||||
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
|
||||
|
||||
'@types/uuid@10.0.0':
|
||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.3':
|
||||
resolution: {integrity: sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
@ -554,6 +609,15 @@ packages:
|
|||
'@vue/devtools-api@6.6.4':
|
||||
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':
|
||||
resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==}
|
||||
|
||||
|
@ -623,6 +687,10 @@ packages:
|
|||
array-flatten@1.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
|
@ -671,6 +739,9 @@ packages:
|
|||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
birpc@2.3.0:
|
||||
resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==}
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
|
@ -835,10 +906,21 @@ packages:
|
|||
cookie-signature@1.0.6:
|
||||
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||
|
||||
cookie-signature@1.0.7:
|
||||
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
|
||||
|
||||
cookie@0.7.1:
|
||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
||||
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:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
|
@ -1068,6 +1150,10 @@ packages:
|
|||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
express-session@1.18.1:
|
||||
resolution: {integrity: sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
express@4.21.2:
|
||||
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
@ -1230,6 +1316,9 @@ packages:
|
|||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
html-minifier-terser@7.2.0:
|
||||
resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==}
|
||||
engines: {node: ^14.13.1 || >=16.0.0}
|
||||
|
@ -1341,6 +1430,10 @@ packages:
|
|||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
is-what@4.1.16:
|
||||
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
|
||||
engines: {node: '>=12.13'}
|
||||
|
||||
is-wsl@2.2.0:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -1492,6 +1585,9 @@ packages:
|
|||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mkdirp-classic@0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
|
@ -1653,6 +1749,9 @@ packages:
|
|||
peberminta@0.9.0:
|
||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
|
@ -1664,6 +1763,15 @@ packages:
|
|||
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
|
@ -1713,6 +1821,13 @@ packages:
|
|||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
@ -1721,6 +1836,10 @@ packages:
|
|||
resolution: {integrity: sha512-db/P64Mzpt1uXJ0MapaG+IYJQ9hHDb5KtTCoszwC78DR7sA+Uoj7nBW2EytwYykIExEmqavOvKrdasTvqhkgEg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
|
@ -1773,6 +1892,9 @@ packages:
|
|||
restructure@3.0.2:
|
||||
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rollup-plugin-visualizer@5.14.0:
|
||||
resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==}
|
||||
engines: {node: '>=18'}
|
||||
|
@ -2036,6 +2158,10 @@ packages:
|
|||
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stack-trace@1.0.0-pre2:
|
||||
resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==}
|
||||
engines: {node: '>=16'}
|
||||
|
@ -2073,6 +2199,10 @@ packages:
|
|||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
superjson@2.2.2:
|
||||
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -2165,6 +2295,10 @@ packages:
|
|||
ufo@1.6.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
|
@ -2198,6 +2332,10 @@ packages:
|
|||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
@ -2475,6 +2613,8 @@ snapshots:
|
|||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@inquirer/figures@1.0.11': {}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
|
@ -2508,6 +2648,41 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@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':
|
||||
optional: true
|
||||
|
||||
|
@ -2543,7 +2718,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@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:
|
||||
'@quasar/render-ssr-error': 1.0.3
|
||||
'@quasar/ssl-certificate': 1.0.0
|
||||
|
@ -2585,6 +2760,8 @@ snapshots:
|
|||
vue: 3.5.13
|
||||
vue-router: 4.5.0(vue@3.5.13)
|
||||
webpack-merge: 6.0.1
|
||||
optionalDependencies:
|
||||
pinia: 3.0.2(vue@3.5.13)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
|
@ -2683,6 +2860,18 @@ snapshots:
|
|||
domhandler: 5.0.3
|
||||
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':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
@ -2716,6 +2905,10 @@ snapshots:
|
|||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.4
|
||||
|
||||
'@types/express-session@1.18.1':
|
||||
dependencies:
|
||||
'@types/express': 4.17.21
|
||||
|
||||
'@types/express@4.17.21':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.5
|
||||
|
@ -2758,6 +2951,8 @@ snapshots:
|
|||
'@types/node': 22.14.1
|
||||
'@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)':
|
||||
dependencies:
|
||||
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@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':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.13
|
||||
|
@ -2873,6 +3086,12 @@ snapshots:
|
|||
|
||||
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: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
@ -2921,6 +3140,8 @@ snapshots:
|
|||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
|
||||
birpc@2.3.0: {}
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
|
@ -3112,8 +3333,16 @@ snapshots:
|
|||
|
||||
cookie-signature@1.0.6: {}
|
||||
|
||||
cookie-signature@1.0.7: {}
|
||||
|
||||
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: {}
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
@ -3330,6 +3559,19 @@ snapshots:
|
|||
|
||||
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:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
|
@ -3548,6 +3790,8 @@ snapshots:
|
|||
|
||||
he@1.2.0: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
html-minifier-terser@7.2.0:
|
||||
dependencies:
|
||||
camel-case: 4.1.2
|
||||
|
@ -3670,6 +3914,8 @@ snapshots:
|
|||
|
||||
is-unicode-supported@0.1.0: {}
|
||||
|
||||
is-what@4.1.16: {}
|
||||
|
||||
is-wsl@2.2.0:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
@ -3817,6 +4063,8 @@ snapshots:
|
|||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mkdirp-classic@0.5.3: {}
|
||||
|
||||
mlly@1.7.4:
|
||||
|
@ -3971,12 +4219,19 @@ snapshots:
|
|||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
|
@ -4035,12 +4290,20 @@ snapshots:
|
|||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
pvutils@1.1.3: {}
|
||||
|
||||
qs@6.13.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
quasar@2.18.1: {}
|
||||
|
||||
random-bytes@1.0.0: {}
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
@ -4113,6 +4376,8 @@ snapshots:
|
|||
|
||||
restructure@3.0.2: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rollup-plugin-visualizer@5.14.0(rollup@4.40.0):
|
||||
dependencies:
|
||||
open: 8.4.2
|
||||
|
@ -4381,6 +4646,8 @@ snapshots:
|
|||
|
||||
source-map@0.7.4: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
stack-trace@1.0.0-pre2: {}
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
@ -4422,6 +4689,10 @@ snapshots:
|
|||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
superjson@2.2.2:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
@ -4510,6 +4781,10 @@ snapshots:
|
|||
|
||||
ufo@1.6.1: {}
|
||||
|
||||
uid-safe@2.1.5:
|
||||
dependencies:
|
||||
random-bytes: 1.0.0
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
unicode-properties@1.4.1:
|
||||
|
@ -4540,6 +4815,8 @@ snapshots:
|
|||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
onlyBuiltDependencies:
|
||||
- '@prisma/client'
|
||||
- '@prisma/engines'
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- prisma
|
||||
- sqlite3
|
||||
|
|
|
@ -99,3 +99,27 @@ model Setting {
|
|||
|
||||
@@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")
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 128 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 859 B After Width: | Height: | Size: 1 KiB |
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 5.8 KiB |
BIN
public/stylepoint.png
Normal file
After Width: | Height: | Size: 662 KiB |
390
src-ssr/routes/auth.js
Normal 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;
|
|
@ -11,6 +11,8 @@
|
|||
*/
|
||||
import express from 'express'
|
||||
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 {
|
||||
defineSsrCreate,
|
||||
defineSsrListen,
|
||||
|
@ -21,9 +23,18 @@ import {
|
|||
|
||||
import prisma from './database.js'; // Import the prisma client instance
|
||||
import apiRoutes from './routes/api.js';
|
||||
import authRoutes from './routes/auth.js'; // Added for WebAuthn routes
|
||||
import cron from 'node-cron';
|
||||
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.
|
||||
* If needed, prepare your webserver to receive
|
||||
|
@ -34,6 +45,19 @@ import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
|
|||
export const create = defineSsrCreate((/* { ... } */) => {
|
||||
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)
|
||||
try {
|
||||
console.log('Prisma Client is ready.'); // Log Prisma readiness
|
||||
|
@ -72,6 +96,7 @@ export const create = defineSsrCreate((/* { ... } */) => {
|
|||
|
||||
// Add API routes
|
||||
app.use('/api', apiRoutes);
|
||||
app.use('/auth', authRoutes); // Added WebAuthn auth routes
|
||||
|
||||
// place here any middlewares that
|
||||
// absolutely need to run before anything else
|
||||
|
|
|
@ -16,71 +16,42 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple :to="{ name: 'formList' }" exact>
|
||||
<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>
|
||||
|
||||
<!-- Dynamic Navigation Items -->
|
||||
<q-item
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{ name: 'mantisSummaries' }"
|
||||
:to="{ name: item.name }"
|
||||
exact
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Mantis Summaries</span>
|
||||
<span>{{ item.meta.title }}</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="summarize" />
|
||||
<q-icon :name="item.meta.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Mantis Summaries</q-item-label>
|
||||
<q-item-label caption>View daily summaries</q-item-label>
|
||||
<q-item-label>{{ item.meta.title }}</q-item-label>
|
||||
<q-item-label caption>{{ item.meta.caption }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Logout Button (Conditional) -->
|
||||
<q-item
|
||||
v-if="authStore.isAuthenticated"
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{ name: 'emailSummaries' }"
|
||||
exact
|
||||
@click="logout"
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Email Summaries</span>
|
||||
<span>Logout</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="email" />
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Email Summaries</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-label>Logout</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
|
@ -94,11 +65,55 @@
|
|||
</template>
|
||||
|
||||
<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 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 () {
|
||||
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>
|
||||
|
|
36
src/pages/LandingPage.vue
Normal 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
|
@ -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>
|
275
src/pages/PasskeyManagementPage.vue
Normal 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
|
@ -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>
|
|
@ -1,6 +1,7 @@
|
|||
import { defineRouter } from '#q-app/wrappers'
|
||||
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||
import routes from './routes'
|
||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
|
@ -11,7 +12,7 @@ import routes from './routes'
|
|||
* with the Router instance.
|
||||
*/
|
||||
|
||||
export default defineRouter(function (/* { store, ssrContext } */) {
|
||||
export default defineRouter(function ({ store /* { store, ssrContext } */ }) {
|
||||
const createHistory = process.env.SERVER
|
||||
? createMemoryHistory
|
||||
: (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)
|
||||
})
|
||||
|
||||
// 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
|
||||
})
|
||||
|
|
|
@ -3,15 +3,101 @@ const routes = [
|
|||
path: '/',
|
||||
component: () => import('layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'home', component: () => import('pages/FormListPage.vue') },
|
||||
{ path: 'forms', name: 'formList', component: () => import('pages/FormListPage.vue') },
|
||||
{ path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue') },
|
||||
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true },
|
||||
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true },
|
||||
{ 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: 'settings', name: 'settings', component: () => import('pages/SettingsPage.vue') }
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('pages/LandingPage.vue'),
|
||||
meta: { requiresAuth: false } // Keep home accessible, but don't show in nav
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
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
|
@ -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
|
@ -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
|
||||
})
|