feat(web): make things work

- setup basic login form
- setup basic add institution functionality
This commit is contained in:
Stevan Freeborn
2026-02-21 07:32:28 -06:00
parent e2537d77c4
commit d9130e75a4
12 changed files with 408 additions and 44 deletions
+106 -24
View File
@@ -8,6 +8,7 @@
"name": "fiscalos-web", "name": "fiscalos-web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@jcss/vue-plaid-link": "^1.1.3",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"ts-results": "^3.3.0", "ts-results": "^3.3.0",
@@ -1416,6 +1417,16 @@
"node": ">=12" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2295,6 +2306,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.0", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", "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": { "node_modules/abbrev": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@@ -3172,9 +3225,9 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@@ -3342,13 +3395,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/bidi-js": { "node_modules/bidi-js": {
@@ -3870,9 +3926,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.286", "version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -4113,9 +4169,9 @@
} }
}, },
"node_modules/eslint-plugin-playwright": { "node_modules/eslint-plugin-playwright": {
"version": "2.6.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.6.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.7.0.tgz",
"integrity": "sha512-Lo/na1ldsJdKA93OZC5lNdSyzTiJdaIpkxwPDx1PDnEc/Ogkt72il5TJ9J5MZSo4FlybM4h4rxPe/31sdD0MSA==", "integrity": "sha512-kUgwDZL3knnuJF53WSf5xNnB1aLPnX8furoh0PSrmmFIfMfIMmY3sNd4gtZ2MUUnaIX1/A9ndYtD7bhV1dj+1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5351,11 +5407,11 @@
} }
}, },
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.2", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true, "dev": true,
"license": "ISC", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
@@ -7260,6 +7316,32 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vue-eslint-parser": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
@@ -7311,14 +7393,14 @@
} }
}, },
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "5.0.2", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
"integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==", "integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
"@vue-macros/common": "^3.1.1", "@vue-macros/common": "^3.1.1",
"@vue/devtools-api": "^8.0.0", "@vue/devtools-api": "^8.0.6",
"ast-walker-scope": "^0.8.3", "ast-walker-scope": "^0.8.3",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"json5": "^2.2.3", "json5": "^2.2.3",
@@ -7473,9 +7555,9 @@
} }
}, },
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "16.0.0", "version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1
View File
@@ -17,6 +17,7 @@
"format": "prettier --write --experimental-cli src/" "format": "prettier --write --experimental-cli src/"
}, },
"dependencies": { "dependencies": {
"@jcss/vue-plaid-link": "^1.1.3",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"ts-results": "^3.3.0", "ts-results": "^3.3.0",
@@ -20,6 +20,7 @@ button {
input { input {
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
color: inherit;
} }
a { a {
@@ -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>
@@ -1,13 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router';
import NavSidebar from './NavSidebar.vue';
</script> </script>
<template> <template>
<div> <div class="layout">
<main>
<RouterView />
</main>
<NavSidebar />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.layout {
display: flex;
height: 100%;
position: relative;
}
main {
flex: 1;
border: 1px solid blue;
}
</style> </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 { 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;
}
+2
View File
@@ -7,11 +7,13 @@ import App from "./App.vue";
import router from "./router"; import router from "./router";
import { ClientFactory, ClientFactoryKey } from "./services/client"; import { ClientFactory, ClientFactoryKey } from "./services/client";
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService"; import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
const app = createApp(App); const app = createApp(App);
app.provide(ClientFactoryKey, new ClientFactory()); app.provide(ClientFactoryKey, new ClientFactory());
app.provide(AuthServiceFactoryKey, new AuthServiceFactory()); app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
app.use(createPinia()); app.use(createPinia());
app.use(router); app.use(router);
+17 -11
View File
@@ -1,7 +1,7 @@
import { AuthService } from "@/services/authService"; import { AuthService } from '@/services/authService';
import { Client, ClientConfig } from "@/services/client"; import { Client, ClientConfig } from '@/services/client';
import { useUserStore } from "@/stores/userStore"; import { useUserStore } from '@/stores/userStore';
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -22,24 +22,25 @@ const router = createRouter({
children: [ children: [
{ {
path: 'login', path: 'login',
component: () => import('../views/LoginView.vue') component: () => import('../views/LoginView.vue'),
} },
], ],
}, },
{ {
path: '/', path: '/',
component: () => import('../components/ProtectedLayout.vue'),
beforeEnter: async () => { beforeEnter: async () => {
const userStore = useUserStore(); const userStore = useUserStore();
if (userStore.user === null) { if (userStore.user === null) {
return { path: '/public/login' } return { path: '/public/login' };
} }
const isExpired = userStore.user.expiresAtInSeconds < Date.now() / 1000; const isExpired = userStore.user.expiresAtInSeconds < Date.now() / 1000;
if (isExpired) { if (isExpired) {
const clientConfig = new ClientConfig( const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${userStore.user.token}`}, { Authorization: `Bearer ${userStore.user.token}` },
true true
); );
const client = new Client(clientConfig); const client = new Client(clientConfig);
@@ -48,7 +49,7 @@ const router = createRouter({
if (refreshResult.err) { if (refreshResult.err) {
userStore.logUserOut(); userStore.logUserOut();
return { path: '/public/login' } return { path: '/public/login' };
} }
userStore.logUserIn(refreshResult.val.accessToken); userStore.logUserIn(refreshResult.val.accessToken);
@@ -56,8 +57,13 @@ const router = createRouter({
return true; return true;
}, },
component: () => import('../views/HomeView.vue'), children: [
} {
path: '/',
component: () => import('../views/HomeView.vue'),
},
],
},
], ],
}); });
@@ -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;
};
+60 -4
View File
@@ -1,9 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import { useInstitutionService } from '@/composables/useInstitutionService';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
import { nextTick, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const { logUserOut } = useUserStore(); const userStore = useUserStore();
const router = useRouter(); 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() { function handleLogout() {
// TODO: Also need to log user // TODO: Also need to log user
@@ -11,16 +37,46 @@ function handleLogout() {
// basically just clearing // basically just clearing
// refresh token cookie and revoking // refresh token cookie and revoking
// it in the database // it in the database
logUserOut(); userStore.logUserOut();
router.push({ path: '/public/login' }); 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> </script>
<template> <template>
<h1>Home View</h1> <h1>Home View</h1>
<button type="button" v-on:click="handleLogout"> <button class="logout-button" type="button" @click="handleLogout">
Logout Logout
</button> </button>
<div>
<button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
Add Institution
</button>
</div>
</template> </template>
<style scoped></style> <style scoped>
.logout-button,
.add-institution-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
</style>
+2 -2
View File
@@ -15,7 +15,7 @@ async function handleSubmit(e: SubmitEvent) {
); );
if (loginResult.err) { if (loginResult.err) {
alert(loginResult.val.join('\n')); alert(loginResult.val.map(e => e.message).join('\n'));
return; return;
} }
@@ -25,7 +25,7 @@ async function handleSubmit(e: SubmitEvent) {
</script> </script>
<template> <template>
<form v-on:submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div> <div>
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" name="username" id="username" /> <input type="text" name="username" id="username" />