Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ea205ac7 | ||
|
|
3449347bb4 | ||
|
|
d45f4aa1e3 | ||
|
|
1a851d25bb | ||
|
|
eb1ac2ff3d | ||
|
|
c37aa7ccca | ||
|
|
ee074a29ff | ||
|
|
2dc6e96e18 | ||
|
|
12cf9f1e95 | ||
|
|
417df002a0 | ||
|
|
be2e991c8e |
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"dotnet.defaultSolution": "FiscalOS.slnx",
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"endOfLine": "auto",
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"singleAttributePerLine": true
|
||||
}
|
||||
|
||||
Generated
+205
-106
@@ -8,7 +8,10 @@
|
||||
"name": "fiscalos-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@jcss/vue-plaid-link": "^1.1.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"ts-results": "^3.3.0",
|
||||
"vue": "beta",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
@@ -1414,6 +1417,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jcss/vue-plaid-link": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jcss/vue-plaid-link/-/vue-plaid-link-1.1.3.tgz",
|
||||
"integrity": "sha512-h6uV2Muh0MdVKeOp/fY2/OYMgy9kQw7JA4HoE/PkfNutVsrO/foXxczLlReUhtPq2erdOepri3Du5W1CKxFc/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.7.2",
|
||||
"vue": "^3.4.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2293,18 +2306,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
||||
"integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
|
||||
"integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/type-utils": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/type-utils": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
@@ -2317,8 +2336,8 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
@@ -2333,16 +2352,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
|
||||
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2353,19 +2372,19 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
|
||||
"integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
|
||||
"integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.55.0",
|
||||
"@typescript-eslint/types": "^8.55.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.56.0",
|
||||
"@typescript-eslint/types": "^8.56.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2380,14 +2399,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
|
||||
"integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
|
||||
"integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0"
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2398,9 +2417,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
|
||||
"integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
|
||||
"integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2415,15 +2434,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
|
||||
"integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
|
||||
"integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
@@ -2435,14 +2454,14 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
|
||||
"integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
|
||||
"integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2454,16 +2473,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
|
||||
"integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
|
||||
"integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.55.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/visitor-keys": "8.55.0",
|
||||
"@typescript-eslint/project-service": "8.56.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/visitor-keys": "8.56.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^9.0.5",
|
||||
"semver": "^7.7.3",
|
||||
@@ -2495,16 +2514,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
|
||||
"integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
|
||||
"integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0"
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2514,19 +2533,19 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
|
||||
"integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
|
||||
"integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2537,13 +2556,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
|
||||
"integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -3012,22 +3031,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/eslint-config-typescript": {
|
||||
"version": "14.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.6.0.tgz",
|
||||
"integrity": "sha512-UpiRY/7go4Yps4mYCjkvlIbVWmn9YvPGQDxTAlcKLphyaD77LjIu3plH4Y9zNT0GB4f3K5tMmhhtRhPOgrQ/bQ==",
|
||||
"version": "14.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.7.0.tgz",
|
||||
"integrity": "sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "^8.35.1",
|
||||
"@typescript-eslint/utils": "^8.56.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"vue-eslint-parser": "^10.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^9.10.0",
|
||||
"eslint": "^9.10.0 || ^10.0.0",
|
||||
"eslint-plugin-vue": "^9.28.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4"
|
||||
},
|
||||
@@ -3159,6 +3178,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vueuse/metadata": "10.11.1",
|
||||
"@vueuse/shared": "10.11.1",
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
@@ -3170,9 +3225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -3340,13 +3395,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
@@ -3868,9 +3926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -4111,13 +4169,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-playwright": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.5.1.tgz",
|
||||
"integrity": "sha512-q7oqVQTTfa3VXJQ8E+ln0QttPGrs/XmSO1FjOMzQYBMYF3btih4FIrhEYh34JF184GYDmq3lJ/n7CMa49OHBvA==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.7.0.tgz",
|
||||
"integrity": "sha512-kUgwDZL3knnuJF53WSf5xNnB1aLPnX8furoh0PSrmmFIfMfIMmY3sNd4gtZ2MUUnaIX1/A9ndYtD7bhV1dj+1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"globals": "^16.4.0"
|
||||
"globals": "^17.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -4127,9 +4185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-playwright/node_modules/globals": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
|
||||
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
|
||||
"version": "17.3.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
|
||||
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5145,6 +5203,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5340,11 +5407,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
@@ -6604,6 +6671,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-results": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-results/-/ts-results-3.3.0.tgz",
|
||||
"integrity": "sha512-FWqxGX2NHp5oCyaMd96o2y2uMQmSu8Dey6kvyuFdRJ2AzfmWo3kWa4UsPlCGlfQ/qu03m09ZZtppMoY8EMHuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -6632,16 +6705,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz",
|
||||
"integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
|
||||
"integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.55.0",
|
||||
"@typescript-eslint/parser": "8.55.0",
|
||||
"@typescript-eslint/typescript-estree": "8.55.0",
|
||||
"@typescript-eslint/utils": "8.55.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
||||
"@typescript-eslint/parser": "8.56.0",
|
||||
"@typescript-eslint/typescript-estree": "8.56.0",
|
||||
"@typescript-eslint/utils": "8.56.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -6651,7 +6724,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
@@ -7243,6 +7316,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-eslint-parser": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
|
||||
@@ -7294,14 +7393,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz",
|
||||
"integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
|
||||
"integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@vue-macros/common": "^3.1.1",
|
||||
"@vue/devtools-api": "^8.0.0",
|
||||
"@vue/devtools-api": "^8.0.6",
|
||||
"ast-walker-scope": "^0.8.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"json5": "^2.2.3",
|
||||
@@ -7456,9 +7555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz",
|
||||
"integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==",
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jcss/vue-plaid-link": "^1.1.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"ts-results": "^3.3.0",
|
||||
"vue": "beta",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>FiscalOS</h1>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import App from "../App.vue";
|
||||
|
||||
describe("App", () => {
|
||||
it("mounts renders properly", () => {
|
||||
const wrapper = mount(App);
|
||||
expect(wrapper.text()).toContain("You did it!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import LoginForm from '@/components/LoginForm.vue';
|
||||
|
||||
describe('LoginForm', () => {
|
||||
it('should have username field', () => {
|
||||
const wrapper = mount(LoginForm);
|
||||
|
||||
const username = wrapper.get('#username');
|
||||
|
||||
expect(username.element.tagName).toBe('INPUT');
|
||||
expect(username.attributes()['type']).toBe('text');
|
||||
});
|
||||
|
||||
it('should have password field', () => {
|
||||
const wrapper = mount(LoginForm);
|
||||
|
||||
const password = wrapper.get('#password');
|
||||
|
||||
expect(password.element.tagName).toBe('INPUT');
|
||||
expect(password.attributes()['type']).toBe('password');
|
||||
});
|
||||
|
||||
it('should require username field on submission', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Username is required');
|
||||
});
|
||||
|
||||
it('should require password field on submission', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Password is required');
|
||||
});
|
||||
|
||||
it('should reset error state when new input is entered', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Username is required');
|
||||
expect(wrapper.text()).toContain('Password is required');
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
|
||||
expect(wrapper.text()).not.toContain('Username is required');
|
||||
expect(wrapper.text()).not.toContain('Password is required');
|
||||
});
|
||||
|
||||
it('should disable login button while submitting', async () => {
|
||||
let resolveSubmit;
|
||||
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
onValidSubmit: () => new Promise(resolve => {
|
||||
resolveSubmit = resolve;
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
const submitButton = wrapper.get('button');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect('disabled' in submitButton.attributes()).toBe(true);
|
||||
|
||||
resolveSubmit!();
|
||||
await flushPromises();
|
||||
|
||||
expect('disabled' in submitButton.attributes()).toBe(false);
|
||||
});
|
||||
|
||||
it('should call the onValidSubmit function when submitted with valid state', async () => {
|
||||
let wasCalled = false;
|
||||
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
onValidSubmit: () => new Promise(resolve => {
|
||||
wasCalled = true;
|
||||
resolve();
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
const submitButton = wrapper.get('button');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect(wasCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
@import './reset.css';
|
||||
|
||||
@font-face {
|
||||
font-family: 'CaskaydiaCove NFM';
|
||||
src: url('../fonts/CaskaydiaCoveNFM-Regular.eot');
|
||||
src:
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.woff') format('woff'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--palette-lavender: #b39cd0;
|
||||
--palette-rose: #d09c9f;
|
||||
--palette-sage: #b9d09c;
|
||||
--palette-mint: #9cd0cd;
|
||||
|
||||
--neutral-white: #ffffff;
|
||||
--neutral-100: #f5f5f5;
|
||||
--neutral-200: #e5e5e5;
|
||||
--neutral-800: #262626;
|
||||
--neutral-900: #171717;
|
||||
--neutral-black: #000000;
|
||||
|
||||
--bg-app: var(--neutral-white);
|
||||
--bg-surface: var(--neutral-100);
|
||||
--bg-element: var(--neutral-200);
|
||||
|
||||
--text-primary: var(--neutral-black);
|
||||
--text-secondary: var(--neutral-800);
|
||||
--border-subtle: var(--neutral-200);
|
||||
|
||||
--brand-primary: var(--palette-lavender);
|
||||
--brand-secondary: var(--palette-rose);
|
||||
--brand-accent: var(--palette-mint);
|
||||
--state-success: var(--palette-sage);
|
||||
--state-error: var(--palette-rose);
|
||||
|
||||
font-family: 'CaskaydiaCove NFM', monospace;
|
||||
font-size: 16px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-app: var(--neutral-black);
|
||||
--bg-surface: var(--neutral-900);
|
||||
--bg-element: var(--neutral-800);
|
||||
|
||||
--text-primary: var(--neutral-white);
|
||||
--text-secondary: var(--neutral-200);
|
||||
--border-subtle: var(--neutral-800);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 6.4 MiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
export type LoginFormState = {
|
||||
isLoggingIn: boolean;
|
||||
username: {
|
||||
value: string;
|
||||
error: string;
|
||||
};
|
||||
password: {
|
||||
value: string;
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
|
||||
}>();
|
||||
|
||||
const formState = ref<LoginFormState>({
|
||||
isLoggingIn: false,
|
||||
username: {
|
||||
value: '',
|
||||
error: '',
|
||||
},
|
||||
password: {
|
||||
value: '',
|
||||
error: '',
|
||||
},
|
||||
});
|
||||
|
||||
function handleUsernameInput(e: Event) {
|
||||
formState.value.username.value = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
if (formState.value.username.error.trim()) {
|
||||
formState.value.username.error = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordInput(e: Event) {
|
||||
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
if (formState.value.password.error.trim()) {
|
||||
formState.value.password.error = '';
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormState() {
|
||||
let isValid = true;
|
||||
|
||||
if (!formState.value.username.value.trim()) {
|
||||
formState.value.username.error = 'Username is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formState.value.password.value.trim()) {
|
||||
formState.value.password.error = 'Password is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
formState.value.isLoggingIn = true;
|
||||
|
||||
try {
|
||||
if (validateFormState() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.onValidSubmit !== undefined) {
|
||||
await props.onValidSubmit(formState.value);
|
||||
}
|
||||
|
||||
} finally {
|
||||
formState.value.isLoggingIn = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
|
||||
<div class="error">{{ formState.username.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" :value="formState.password.value"
|
||||
@input="handlePasswordInput" />
|
||||
<div class="error">{{ formState.password.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" :disabled="formState.isLoggingIn">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
form,
|
||||
form>div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
form {
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
form>div {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
form>div label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
form>div input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid black;
|
||||
background-color: var(--bg-element);
|
||||
}
|
||||
|
||||
form>div button {
|
||||
background-color: var(--brand-primary);
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
form>div button:disabled {
|
||||
opacity: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
form>div .error {
|
||||
color: var(--state-error);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const isCollapsed = ref(false);
|
||||
const asideClasses = computed(() => ({
|
||||
collapsed: isCollapsed.value,
|
||||
}));
|
||||
|
||||
function handleToggleButtonClick() {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="asideClasses">
|
||||
<button
|
||||
@click="handleToggleButtonClick"
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
aside {
|
||||
--sidebar-width: 15.625rem;
|
||||
--button-size: 1.5rem;
|
||||
--transition-duration: 0.5s;
|
||||
--transition-function: ease-in-out;
|
||||
position: relative;
|
||||
width: var(--sidebar-width);
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
background: var(--bg-surface);
|
||||
transition-property: width, transform;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-function);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 48rem) {
|
||||
aside {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 3rem;
|
||||
left: calc(-1 * var(--button-size) / 2);
|
||||
height: var(--button-size);
|
||||
width: var(--button-size);
|
||||
background: var(--bg-element);
|
||||
border-radius: 50%;
|
||||
transition-property: transform;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-function);
|
||||
}
|
||||
|
||||
aside.collapsed {
|
||||
width: calc(0.125rem + var(--button-size) / 2);
|
||||
}
|
||||
|
||||
aside.collapsed .toggle-button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toggle-button svg {
|
||||
--size: 1rem;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import NavSidebar from './NavSidebar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
<NavSidebar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
border: 1px solid blue;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path
|
||||
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AuthServiceFactoryKey } from "@/services/authService";
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
|
||||
export function useAuthService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const authServiceFactory = inject(AuthServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (authServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject auth service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const authService = authServiceFactory.create(client);
|
||||
|
||||
return authService;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import { InstituionServiceFactoryKey } from "@/services/institutionService";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
|
||||
export function useInstitutionService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const institutionServiceFactory = inject(InstituionServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (institutionServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject institution service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const institutionService = institutionServiceFactory.create(client);
|
||||
|
||||
return institutionService;
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import './assets/css/main.css';
|
||||
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { ClientFactory, ClientFactoryKey } from "./services/client";
|
||||
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
|
||||
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.provide(ClientFactoryKey, new ClientFactory());
|
||||
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
||||
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
|
||||
@@ -1,8 +1,70 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { AuthService } from '@/services/authService';
|
||||
import { Client, ClientConfig } from '@/services/client';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [],
|
||||
routes: [
|
||||
{
|
||||
path: '/public',
|
||||
component: () => import('../components/PublicLayout.vue'),
|
||||
redirect: '/public/login',
|
||||
beforeEnter: () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.user) {
|
||||
return { path: '/' };
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../components/ProtectedLayout.vue'),
|
||||
beforeEnter: async () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.user === null) {
|
||||
return { path: '/public/login' };
|
||||
}
|
||||
|
||||
const isExpired = userStore.user.expiresAtInSeconds < Date.now() / 1000;
|
||||
|
||||
if (isExpired) {
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${userStore.user.token}` },
|
||||
true
|
||||
);
|
||||
const client = new Client(clientConfig);
|
||||
const authService = new AuthService(client);
|
||||
const refreshResult = await authService.refreshToken();
|
||||
|
||||
if (refreshResult.err) {
|
||||
userStore.logUserOut();
|
||||
return { path: '/public/login' };
|
||||
}
|
||||
|
||||
userStore.logUserIn(refreshResult.val.accessToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Err, Ok, Result } from 'ts-results';
|
||||
import { type InjectionKey } from 'vue';
|
||||
import { ClientRequestWithBody, type IClient } from './client';
|
||||
|
||||
type AuthServiceFactoryKeyType = InjectionKey<IAuthServiceFactory>;
|
||||
|
||||
export const AuthServiceFactoryKey: AuthServiceFactoryKeyType = Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IAuthServiceFactory {
|
||||
create: (client: IClient) => IAuthService;
|
||||
}
|
||||
|
||||
export class AuthServiceFactory implements IAuthServiceFactory {
|
||||
create(client: IClient): IAuthService {
|
||||
return new AuthService(client);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAuthService {
|
||||
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
|
||||
refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
|
||||
}
|
||||
|
||||
export class AuthService implements IAuthService {
|
||||
private readonly client: IClient;
|
||||
private readonly endpoints = {
|
||||
login: '/api/auth/login',
|
||||
refreshToken: '/api/auth/refresh',
|
||||
};
|
||||
|
||||
constructor(client: IClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const request = new ClientRequestWithBody(
|
||||
this.endpoints.login,
|
||||
undefined,
|
||||
new LoginRequest(username, password)
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 400) {
|
||||
const body = await res.json();
|
||||
const validationErrors = body.errors as Record<string, string[]>;
|
||||
const errors = Object.values(validationErrors)
|
||||
.flat()
|
||||
.map(e => new Error(e));
|
||||
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Email/Password combination is not valid')]);
|
||||
}
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Login failed. Please try again.')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LoginResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Login failed. Please try again.')]);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
const request = new ClientRequestWithBody(this.endpoints.refreshToken, undefined, undefined);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Refresh and/or access token is not valid')]);
|
||||
}
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Refreshing token failed')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LoginResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Refreshing token failed.')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BaseAuthRequest {
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
|
||||
constructor(username: string, password: string) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
class LoginRequest extends BaseAuthRequest {
|
||||
constructor(username: string, password: string) {
|
||||
super(username, password);
|
||||
}
|
||||
}
|
||||
|
||||
type LoginResponse = {
|
||||
accessToken: string;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
type ClientKeyType = InjectionKey<IClientFactory>;
|
||||
|
||||
export const ClientFactoryKey: ClientKeyType = Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IClientFactory {
|
||||
create: (config?: ClientConfig) => IClient;
|
||||
}
|
||||
|
||||
export class ClientFactory implements IClientFactory {
|
||||
create(config?: ClientConfig): IClient {
|
||||
return new Client(config);
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientRequest {
|
||||
readonly url: string;
|
||||
readonly config?: RequestInit;
|
||||
|
||||
constructor(url: string, config?: RequestInit) {
|
||||
this.url = url;
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientRequestWithBody<T> extends ClientRequest {
|
||||
body: T | undefined;
|
||||
|
||||
constructor(url: string, config?: RequestInit, body?: T) {
|
||||
super(url, config);
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export type UnauthorizedResponseHandler =
|
||||
| ((originalRequest: Request) => Promise<{ response: Response; accessToken: string }>)
|
||||
| undefined;
|
||||
|
||||
export class ClientConfig {
|
||||
authHeader: Record<string, string> | undefined;
|
||||
includeCredentials: boolean | undefined;
|
||||
unauthorizedResponseHandler: UnauthorizedResponseHandler;
|
||||
|
||||
constructor(
|
||||
authHeader?: Record<string, string> | undefined,
|
||||
includeCredentials?: boolean | undefined,
|
||||
unauthorizedResponseHandler?: UnauthorizedResponseHandler
|
||||
) {
|
||||
this.authHeader = authHeader;
|
||||
this.includeCredentials = includeCredentials;
|
||||
this.unauthorizedResponseHandler = unauthorizedResponseHandler;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IClient {
|
||||
get: (req: ClientRequest) => Promise<Response>;
|
||||
post: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
put: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
patch: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
delete: (req: ClientRequest) => Promise<Response>;
|
||||
}
|
||||
|
||||
export class Client implements IClient {
|
||||
private readonly _clientConfig: ClientConfig;
|
||||
|
||||
constructor(clientConfig?: ClientConfig) {
|
||||
this._clientConfig = clientConfig ?? new ClientConfig(undefined, true, undefined);
|
||||
}
|
||||
|
||||
private async request(url: string, config?: RequestInit): Promise<Response> {
|
||||
const headers = {
|
||||
...config?.headers,
|
||||
...this._clientConfig?.authHeader,
|
||||
};
|
||||
|
||||
const credentials = this._clientConfig?.includeCredentials
|
||||
? ('include' as RequestCredentials)
|
||||
: ('omit' as RequestCredentials);
|
||||
|
||||
const requestConfig = {
|
||||
...config,
|
||||
headers: headers,
|
||||
credentials: credentials,
|
||||
};
|
||||
|
||||
const firstTryRequest = new Request(url, requestConfig);
|
||||
const secondTryRequest = new Request(url, requestConfig);
|
||||
let response = await fetch(firstTryRequest);
|
||||
|
||||
if (response.status === 401 && this._clientConfig?.unauthorizedResponseHandler) {
|
||||
const retryResult = await this._clientConfig.unauthorizedResponseHandler(secondTryRequest);
|
||||
this._clientConfig.authHeader = { Authorization: `Bearer ${retryResult.accessToken}` };
|
||||
response = retryResult.response;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async get({ url, config }: { url: string; config?: RequestInit }) {
|
||||
const requestConfig = { ...config, method: 'GET' };
|
||||
return await this.request(url, requestConfig);
|
||||
}
|
||||
|
||||
async post<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async put<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async patch<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async delete(req: ClientRequest) {
|
||||
const requestConfig = { ...req?.config, method: 'DELETE' };
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Err, Ok, Result } from 'ts-results';
|
||||
import { type InjectionKey } from 'vue';
|
||||
import { ClientRequestWithBody, type IClient } from './client';
|
||||
|
||||
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
|
||||
|
||||
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
|
||||
Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IInstitutionServiceFactory {
|
||||
create: (client: IClient) => IInstitutionService;
|
||||
}
|
||||
|
||||
export class InstitutionServiceFactory implements InstitutionServiceFactory {
|
||||
create(client: IClient): IInstitutionService {
|
||||
return new InstitutionService(client);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IInstitutionService {
|
||||
createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>;
|
||||
connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>;
|
||||
}
|
||||
|
||||
export class InstitutionService implements IInstitutionService {
|
||||
private readonly client: IClient;
|
||||
private readonly endpoints = {
|
||||
link: '/api/institutions/link',
|
||||
connect: '/api/institutions/connect',
|
||||
};
|
||||
|
||||
constructor(client: IClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async createLinkToken() {
|
||||
const request = new ClientRequestWithBody(this.endpoints.link);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Failed to create link token')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LinkTokenResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Failed to create link token')]);
|
||||
}
|
||||
}
|
||||
|
||||
async connect(publicToken: string, plaidInstitutionId: string) {
|
||||
const request = new ClientRequestWithBody(this.endpoints.connect, undefined, {
|
||||
publicToken,
|
||||
plaidInstitutionId,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 400) {
|
||||
const body = await res.json();
|
||||
const validationErrors = body.errors as Record<string, string[]>;
|
||||
const errors = Object.values(validationErrors)
|
||||
.flat()
|
||||
.map(e => new Error(e));
|
||||
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Please sign in and try again.')]);
|
||||
}
|
||||
|
||||
if (res.status === 409) {
|
||||
return Err([new Error('You have already linked this institution.')]);
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Failed to connect institution')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LinkTokenResponse = {
|
||||
linkToken: string;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { AuthServiceFactoryKey } from '@/services/authService';
|
||||
import { ClientConfig, ClientFactoryKey } from '@/services/client';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { defineStore } from 'pinia';
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
export const USER_KEY = 'fiscalos_auth';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
expiresAtInSeconds: number;
|
||||
token: string;
|
||||
};
|
||||
|
||||
type JwtTokenPayload = {
|
||||
sub: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function getUserFromLocalStorage(): User | null {
|
||||
const user = localStorage.getItem(USER_KEY);
|
||||
return user === null ? null : JSON.parse(user);
|
||||
}
|
||||
|
||||
export type UserStore = ReturnType<typeof useUserStore>;
|
||||
|
||||
export const useUserStore = defineStore('userStore', () => {
|
||||
const user = ref<User | null>(getUserFromLocalStorage());
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const authServiceFactory = inject(AuthServiceFactoryKey);
|
||||
|
||||
function logUserIn(jwtToken: string) {
|
||||
const { sub, exp } = jwtDecode<JwtTokenPayload>(jwtToken);
|
||||
const loggedInUser: User = {
|
||||
id: sub,
|
||||
expiresAtInSeconds: exp,
|
||||
token: jwtToken,
|
||||
};
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(loggedInUser));
|
||||
user.value = loggedInUser;
|
||||
}
|
||||
|
||||
function logUserOut() {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(originalRequest: Request) {
|
||||
const unauthorizedResponse = new Response(null, { status: 401 });
|
||||
|
||||
if (user.value === null || !clientFactory || !authServiceFactory) {
|
||||
logUserOut();
|
||||
return { response: unauthorizedResponse, accessToken: '' };
|
||||
}
|
||||
|
||||
const client = clientFactory.create(
|
||||
new ClientConfig({ Authorization: `Bearer ${user.value.token}` }, true)
|
||||
);
|
||||
|
||||
const authService = authServiceFactory.create(client);
|
||||
|
||||
const refreshResult = await authService.refreshToken();
|
||||
|
||||
if (refreshResult.err) {
|
||||
logUserOut();
|
||||
return { response: unauthorizedResponse, accessToken: '' };
|
||||
}
|
||||
|
||||
logUserIn(refreshResult.val.accessToken);
|
||||
|
||||
originalRequest.headers.set('Authorization', `Bearer ${refreshResult.val.accessToken}`);
|
||||
const response = await fetch(originalRequest);
|
||||
|
||||
return { response, accessToken: refreshResult.val.accessToken };
|
||||
}
|
||||
|
||||
return {
|
||||
user: user,
|
||||
logUserIn,
|
||||
logUserOut,
|
||||
refreshAccessToken,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { useInstitutionService } from '@/composables/useInstitutionService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const institutionService = useInstitutionService(userStore);
|
||||
const plaidOptions = ref<PlaidLinkOptions>({
|
||||
token: '',
|
||||
onSuccess: async (publicToken, metadata) => {
|
||||
if (metadata.institution == null) {
|
||||
alert('Institution information is missing from Plaid response. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectResult = await institutionService.connect(
|
||||
publicToken,
|
||||
metadata.institution.institution_id
|
||||
);
|
||||
|
||||
if (connectResult.err) {
|
||||
alert(connectResult.val.map(e => e.message).join('\n'))
|
||||
return;
|
||||
}
|
||||
},
|
||||
onLoad: () => console.log('loaded'),
|
||||
onExit: () => console.log('exit'),
|
||||
});
|
||||
const { open } = usePlaidLink(plaidOptions);
|
||||
|
||||
function handleLogout() {
|
||||
// TODO: Also need to log user
|
||||
// out on the server...which is
|
||||
// basically just clearing
|
||||
// refresh token cookie and revoking
|
||||
// it in the database
|
||||
userStore.logUserOut();
|
||||
router.push({ path: '/public/login' });
|
||||
}
|
||||
|
||||
async function handleAddInstitutionClick() {
|
||||
const linkTokenResult = await institutionService.createLinkToken();
|
||||
|
||||
if (linkTokenResult.err) {
|
||||
alert(linkTokenResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
plaidOptions.value = {
|
||||
...plaidOptions.value,
|
||||
token: linkTokenResult.val.linkToken,
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Home View</h1>
|
||||
<button class="logout-button" type="button" @click="handleLogout">
|
||||
Logout
|
||||
</button>
|
||||
<div>
|
||||
<button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
|
||||
Add Institution
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logout-button,
|
||||
.add-institution-button {
|
||||
background: var(--bg-element);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
|
||||
import { useAuthService } from '@/composables/useAuthService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authService = useAuthService(userStore);
|
||||
|
||||
async function handleValideSubmit(formState: LoginFormState) {
|
||||
const loginResult = await authService.login(formState.username.value, formState.password.value);
|
||||
|
||||
if (loginResult.err) {
|
||||
alert(loginResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
userStore.logUserIn(loginResult.val.accessToken);
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginForm :onValidSubmit="handleValideSubmit" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -7,6 +7,7 @@ import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import mkcert from 'vite-plugin-mkcert';
|
||||
|
||||
export default defineConfig({
|
||||
publicDir: 'static',
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
|
||||
Reference in New Issue
Block a user