From 3449347bb4d4ebb52d923cf82de7f54fbf49332c Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 20 Feb 2026 05:40:24 -0600 Subject: [PATCH] feat(web): make things work - setup basic login form - setup basic add institution functionality --- src/FiscalOS.Web/package-lock.json | 130 ++++++++++++++---- src/FiscalOS.Web/package.json | 1 + src/FiscalOS.Web/src/assets/css/reset.css | 1 + .../src/components/NavSidebar.vue | 79 +++++++++++ .../src/components/ProtectedLayout.vue | 19 ++- .../src/components/icons/RightArrowIcon.vue | 6 + .../src/composables/useInstitutionService.ts | 27 ++++ src/FiscalOS.Web/src/main.ts | 2 + src/FiscalOS.Web/src/router/index.ts | 28 ++-- .../src/services/institutionService.ts | 91 ++++++++++++ src/FiscalOS.Web/src/views/HomeView.vue | 64 ++++++++- src/FiscalOS.Web/src/views/LoginView.vue | 4 +- 12 files changed, 408 insertions(+), 44 deletions(-) create mode 100644 src/FiscalOS.Web/src/components/NavSidebar.vue create mode 100644 src/FiscalOS.Web/src/components/icons/RightArrowIcon.vue create mode 100644 src/FiscalOS.Web/src/composables/useInstitutionService.ts create mode 100644 src/FiscalOS.Web/src/services/institutionService.ts diff --git a/src/FiscalOS.Web/package-lock.json b/src/FiscalOS.Web/package-lock.json index ad90993..ed7c89c 100644 --- a/src/FiscalOS.Web/package-lock.json +++ b/src/FiscalOS.Web/package-lock.json @@ -8,6 +8,7 @@ "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", @@ -1416,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", @@ -2295,6 +2306,12 @@ "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.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -3161,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", @@ -3172,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" @@ -3342,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": { @@ -3870,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" }, @@ -4113,9 +4169,9 @@ } }, "node_modules/eslint-plugin-playwright": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.6.0.tgz", - "integrity": "sha512-Lo/na1ldsJdKA93OZC5lNdSyzTiJdaIpkxwPDx1PDnEc/Ogkt72il5TJ9J5MZSo4FlybM4h4rxPe/31sdD0MSA==", + "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": { @@ -5351,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" } @@ -7260,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", @@ -7311,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", @@ -7473,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": { diff --git a/src/FiscalOS.Web/package.json b/src/FiscalOS.Web/package.json index 70200e5..a1c63c7 100644 --- a/src/FiscalOS.Web/package.json +++ b/src/FiscalOS.Web/package.json @@ -17,6 +17,7 @@ "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", diff --git a/src/FiscalOS.Web/src/assets/css/reset.css b/src/FiscalOS.Web/src/assets/css/reset.css index 7510895..82de911 100644 --- a/src/FiscalOS.Web/src/assets/css/reset.css +++ b/src/FiscalOS.Web/src/assets/css/reset.css @@ -20,6 +20,7 @@ button { input { font-family: inherit; font-size: inherit; + color: inherit; } a { diff --git a/src/FiscalOS.Web/src/components/NavSidebar.vue b/src/FiscalOS.Web/src/components/NavSidebar.vue new file mode 100644 index 0000000..f830b11 --- /dev/null +++ b/src/FiscalOS.Web/src/components/NavSidebar.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/FiscalOS.Web/src/components/ProtectedLayout.vue b/src/FiscalOS.Web/src/components/ProtectedLayout.vue index d4fea8e..9a25f47 100644 --- a/src/FiscalOS.Web/src/components/ProtectedLayout.vue +++ b/src/FiscalOS.Web/src/components/ProtectedLayout.vue @@ -1,13 +1,26 @@ diff --git a/src/FiscalOS.Web/src/components/icons/RightArrowIcon.vue b/src/FiscalOS.Web/src/components/icons/RightArrowIcon.vue new file mode 100644 index 0000000..dc7b5fa --- /dev/null +++ b/src/FiscalOS.Web/src/components/icons/RightArrowIcon.vue @@ -0,0 +1,6 @@ + diff --git a/src/FiscalOS.Web/src/composables/useInstitutionService.ts b/src/FiscalOS.Web/src/composables/useInstitutionService.ts new file mode 100644 index 0000000..081138c --- /dev/null +++ b/src/FiscalOS.Web/src/composables/useInstitutionService.ts @@ -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; +} diff --git a/src/FiscalOS.Web/src/main.ts b/src/FiscalOS.Web/src/main.ts index b55ed9b..487d805 100644 --- a/src/FiscalOS.Web/src/main.ts +++ b/src/FiscalOS.Web/src/main.ts @@ -7,11 +7,13 @@ 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); diff --git a/src/FiscalOS.Web/src/router/index.ts b/src/FiscalOS.Web/src/router/index.ts index f306038..cb10e76 100644 --- a/src/FiscalOS.Web/src/router/index.ts +++ b/src/FiscalOS.Web/src/router/index.ts @@ -1,7 +1,7 @@ -import { AuthService } from "@/services/authService"; -import { Client, ClientConfig } from "@/services/client"; -import { useUserStore } from "@/stores/userStore"; -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), @@ -22,24 +22,25 @@ const router = createRouter({ children: [ { path: 'login', - component: () => import('../views/LoginView.vue') - } + component: () => import('../views/LoginView.vue'), + }, ], }, { path: '/', + component: () => import('../components/ProtectedLayout.vue'), beforeEnter: async () => { const userStore = useUserStore(); if (userStore.user === null) { - return { path: '/public/login' } + return { path: '/public/login' }; } const isExpired = userStore.user.expiresAtInSeconds < Date.now() / 1000; if (isExpired) { const clientConfig = new ClientConfig( - { Authorization: `Bearer ${userStore.user.token}`}, + { Authorization: `Bearer ${userStore.user.token}` }, true ); const client = new Client(clientConfig); @@ -48,7 +49,7 @@ const router = createRouter({ if (refreshResult.err) { userStore.logUserOut(); - return { path: '/public/login' } + return { path: '/public/login' }; } userStore.logUserIn(refreshResult.val.accessToken); @@ -56,8 +57,13 @@ const router = createRouter({ return true; }, - component: () => import('../views/HomeView.vue'), - } + children: [ + { + path: '/', + component: () => import('../views/HomeView.vue'), + }, + ], + }, ], }); diff --git a/src/FiscalOS.Web/src/services/institutionService.ts b/src/FiscalOS.Web/src/services/institutionService.ts new file mode 100644 index 0000000..f898567 --- /dev/null +++ b/src/FiscalOS.Web/src/services/institutionService.ts @@ -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; + +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>; + connect: (publicToken: string, plaidInstitutionId: string) => Promise>; +} + +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; + 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; +}; diff --git a/src/FiscalOS.Web/src/views/HomeView.vue b/src/FiscalOS.Web/src/views/HomeView.vue index 07779bc..23a86c6 100644 --- a/src/FiscalOS.Web/src/views/HomeView.vue +++ b/src/FiscalOS.Web/src/views/HomeView.vue @@ -1,9 +1,35 @@ - + diff --git a/src/FiscalOS.Web/src/views/LoginView.vue b/src/FiscalOS.Web/src/views/LoginView.vue index 46e3c18..9b554ed 100644 --- a/src/FiscalOS.Web/src/views/LoginView.vue +++ b/src/FiscalOS.Web/src/views/LoginView.vue @@ -15,7 +15,7 @@ async function handleSubmit(e: SubmitEvent) { ); if (loginResult.err) { - alert(loginResult.val.join('\n')); + alert(loginResult.val.map(e => e.message).join('\n')); return; } @@ -25,7 +25,7 @@ async function handleSubmit(e: SubmitEvent) {