From aa6ec75b5469e45c000ae4c22c50f10cca5f463b Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 2 Aug 2025 10:17:44 -0400 Subject: [PATCH] Added remaining API clients, Finn icon. --- finn.svg | 84 ++++++++++++++++++++++ finnow-api/source/api_mapping.d | 17 +++-- finnow-api/source/auth/service.d | 1 - finnow-api/source/auth/tokens.d | 101 -------------------------- finnow-api/source/profile/api.d | 3 +- finnow-api/source/transaction/api.d | 28 +++++++- finnow-api/source/util/pagination.d | 9 +++ web-app/index.html | 8 +-- web-app/public/favicon.ico | Bin 4286 -> 0 bytes web-app/public/finn.png | Bin 0 -> 9688 bytes web-app/src/App.vue | 22 +----- web-app/src/api/account.ts | 46 ++++++++++++ web-app/src/api/auth.ts | 34 ++++++--- web-app/src/api/base.ts | 107 ++++++++++++++++++---------- web-app/src/api/pagination.ts | 27 +++++++ web-app/src/api/profile.ts | 28 ++++++++ web-app/src/api/transaction.ts | 60 ++++++++++++++++ web-app/src/pages/HomePage.vue | 72 +++++++++++++++++++ web-app/src/pages/LoginPage.vue | 50 +++++++++++++ web-app/src/router/index.ts | 24 ++++++- web-app/src/stores/auth-store.ts | 21 ++++++ web-app/src/stores/counter.ts | 12 ---- 22 files changed, 557 insertions(+), 197 deletions(-) create mode 100644 finn.svg delete mode 100644 finnow-api/source/auth/tokens.d delete mode 100644 web-app/public/favicon.ico create mode 100644 web-app/public/finn.png create mode 100644 web-app/src/api/account.ts create mode 100644 web-app/src/api/pagination.ts create mode 100644 web-app/src/api/profile.ts create mode 100644 web-app/src/api/transaction.ts create mode 100644 web-app/src/pages/HomePage.vue create mode 100644 web-app/src/pages/LoginPage.vue create mode 100644 web-app/src/stores/auth-store.ts delete mode 100644 web-app/src/stores/counter.ts diff --git a/finn.svg b/finn.svg new file mode 100644 index 0000000..b9a5c9f --- /dev/null +++ b/finn.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 9ef31b3..c09a99c 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -79,12 +79,6 @@ private void getOptions(ref ServerHttpRequest request, ref ServerHttpResponse re // Do nothing, just return 200 OK. } -private void addCorsHeaders(ref ServerHttpResponse response) { - response.headers.add("Access-Control-Allow-Origin", "*"); - response.headers.add("Access-Control-Allow-Methods", "*"); - response.headers.add("Access-Control-Allow-Headers", "*"); -} - private void sampleDataEndpoint(ref ServerHttpRequest request, ref ServerHttpResponse response) { import slf4d; import util.sample_data; @@ -117,9 +111,14 @@ private class CorsHandler : HttpRequestHandler { } void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) { - response.headers.add("Access-Control-Allow-Origin", "*"); + response.headers.add("Access-Control-Allow-Origin", "http://localhost:5173"); response.headers.add("Access-Control-Allow-Methods", "*"); - response.headers.add("Access-Control-Allow-Headers", "*"); - this.handler.handle(request, response); + response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type"); + try { + this.handler.handle(request, response); + } catch (HttpStatusException e) { + response.status = e.status; + response.writeBodyString(e.message.idup); + } } } diff --git a/finnow-api/source/auth/service.d b/finnow-api/source/auth/service.d index 29dd894..dbf30d2 100644 --- a/finnow-api/source/auth/service.d +++ b/finnow-api/source/auth/service.d @@ -7,7 +7,6 @@ import handy_http_handlers.filtered_handler; import auth.model; import auth.data; import auth.data_impl_fs; -import auth.tokens; const ubyte[] PASSWORD_HASH_PEPPER = []; // Example pepper for password hashing diff --git a/finnow-api/source/auth/tokens.d b/finnow-api/source/auth/tokens.d deleted file mode 100644 index cb85f13..0000000 --- a/finnow-api/source/auth/tokens.d +++ /dev/null @@ -1,101 +0,0 @@ -module auth.tokens; - -import slf4d; -import secured.rsa; -import secured.util; -import std.datetime; -import std.base64; -import std.file; -import asdf : serializeToJson, deserialize; -import handy_http_primitives : Optional, HttpStatusException, HttpStatus; -import streams : Either; - -const TOKEN_EXPIRATION = minutes(60); - -/** - * Definition of the token's payload. - */ -private struct TokenData { - string username; - string issuedAt; -} - -/** - * Definition for the entire token JSON object, including the payload, and a - * signature of the payload generated with the server's private key. - */ -private struct TokenObject { - /// The token's data. - TokenData data; - /// The base64-encoded cryptographic signature of `data`. - string sig; -} - -/** - * Generates a new token for the given user. - * Params: - * username = The username to generate the token for. - * Returns: A new token that the user can provide to authenticate requests. - */ -string generateToken(in string username) { - auto data = TokenData(username, Clock.currTime(UTC()).toISOExtString()); - RSA rsa = getPrivateKey(); - try { - string dataJson = serializeToJson(data); - ubyte[] signature = rsa.sign(cast(ubyte[]) dataJson); - TokenObject obj = TokenObject(data, Base64.encode(signature)); - string jsonStr = serializeToJson(obj); - return Base64.encode(cast(ubyte[]) jsonStr); - } catch (CryptographicException e) { - error("Failed to sign token data.", e); - throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to generate token."); - } -} - -/// Possible errors that can occur when verifying a token. -enum TokenVerificationFailure { - InvalidSignature, - Expired -} - -/** - * Result of token verification, which is the user's username if the token is - * valid, or a failure reason if not. - */ -alias TokenVerificationResult = Either!(string, "username", TokenVerificationFailure, "failure"); - -/** - * Verifies a token and returns the username, if it's valid. - * Params: - * token = The token to verify. - * Returns: A token verification result. - */ -TokenVerificationResult verifyToken(in string token) { - string jsonStr = cast(string) Base64.decode(cast(ubyte[]) token); - TokenObject decodedToken = deserialize!TokenObject(jsonStr); - string dataJson = serializeToJson(decodedToken.data); - ubyte[] signature = Base64.decode(decodedToken.sig); - RSA rsa = getPrivateKey(); - if (!rsa.verify(cast(ubyte[]) dataJson, signature)) { - warnF!"Failed to verify token signature for user: %s"(decodedToken.data.username); - return TokenVerificationResult(TokenVerificationFailure.InvalidSignature); - } - - // We have verified the signature, so now we can check various properties of the token. - - // Check that the token is not expired. - SysTime issuedAt = SysTime.fromISOExtString(decodedToken.data.issuedAt, UTC()); - SysTime now = Clock.currTime(UTC()); - Duration diff = now - issuedAt; - if (diff > TOKEN_EXPIRATION) { - warnF!"Token for user %s has expired."(decodedToken.data.username); - return TokenVerificationResult(TokenVerificationFailure.Expired); - } - - return TokenVerificationResult(decodedToken.data.username); -} - -private RSA getPrivateKey() { - ubyte[] pkData = cast(ubyte[]) readText("test-key"); - return new RSA(pkData, null); -} diff --git a/finnow-api/source/profile/api.d b/finnow-api/source/profile/api.d index c5c337b..e6089dd 100644 --- a/finnow-api/source/profile/api.d +++ b/finnow-api/source/profile/api.d @@ -27,7 +27,8 @@ void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpRespons } AuthContext auth = getAuthContext(request); ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); - profileRepo.createProfile(name); + Profile p = profileRepo.createProfile(name); + writeJsonBody(response, p); } void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse response) { diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 853e54c..bb3ea9c 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -11,14 +11,40 @@ import profile.data; import profile.service; import util.money; import util.pagination; +import util.data; immutable DEFAULT_TRANSACTION_PAGE = PageRequest(0, 10, [Sort("created_at", SortDir.DESC)]); +struct TransactionResponse { + import std.typecons : Nullable, nullable; + ulong id; + string timestamp; + string addedAt; + ulong amount; + string currency; + string description; + Nullable!ulong vendorId; + Nullable!ulong categoryId; + + static TransactionResponse of(in Transaction tx) { + return TransactionResponse( + tx.id, + tx.timestamp.toISOExtString(), + tx.addedAt.toISOExtString(), + tx.amount, + tx.currency.code.idup, + tx.description, + tx.vendorId.toNullable, + tx.categoryId.toNullable + ); + } +} + void getTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); Page!Transaction page = ds.getTransactionRepository().findAll(pr); - writeJsonBody(response, page); + writeJsonBody(response, page.mapItems(&TransactionResponse.of)); } void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { diff --git a/finnow-api/source/util/pagination.d b/finnow-api/source/util/pagination.d index 2dc8130..9419d76 100644 --- a/finnow-api/source/util/pagination.d +++ b/finnow-api/source/util/pagination.d @@ -98,3 +98,12 @@ struct Page(T) { T[] items; PageRequest pageRequest; } + +Page!U mapItems(T, U)(in Page!T page, U function(const(T)) fn) { + import std.algorithm : map; + import std.array : array; + return Page!U( + page.items.map!fn.array, + page.pageRequest + ); +} diff --git a/web-app/index.html b/web-app/index.html index d9459a1..9d4862c 100644 --- a/web-app/index.html +++ b/web-app/index.html @@ -1,9 +1,9 @@ - + - - - + + + Finnow diff --git a/web-app/public/favicon.ico b/web-app/public/favicon.ico deleted file mode 100644 index df36fcfb72584e00488330b560ebcf34a41c64c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S diff --git a/web-app/public/finn.png b/web-app/public/finn.png new file mode 100644 index 0000000000000000000000000000000000000000..058addefd3c9e0eeace8875bafe01e7a05a550a5 GIT binary patch literal 9688 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 -import { onMounted } from 'vue'; -import { AuthApiClient } from './api/auth'; - - -onMounted(async () => { - console.log('mounted!') - const client = new AuthApiClient() - console.log(await client.getApiStatus()) - const token = await client.login('testuser0', 'testpass') - console.log('logged in with token', token) -}) - - + - diff --git a/web-app/src/api/account.ts b/web-app/src/api/account.ts new file mode 100644 index 0000000..4b2b285 --- /dev/null +++ b/web-app/src/api/account.ts @@ -0,0 +1,46 @@ +import { ApiClient } from './base' +import type { Profile } from './profile' + +export interface Account { + id: number + createdAt: string + archived: boolean + type: string + numberSuffix: string + name: string + currency: string + description: string +} + +export interface AccountCreationPayload { + type: string + numberSuffix: string + name: string + currency: string + description: string +} + +export class AccountApiClient extends ApiClient { + readonly path: string + + constructor(profile: Profile) { + super() + this.path = `/profiles/${profile.name}/accounts` + } + + async getAccounts(): Promise { + return super.getJson(this.path) + } + + async getAccount(id: number): Promise { + return super.getJson(this.path + '/' + id) + } + + async createAccount(data: AccountCreationPayload): Promise { + return super.postJson(this.path, data) + } + + async deleteAccount(id: number): Promise { + return super.delete(this.path + '/' + id) + } +} diff --git a/web-app/src/api/auth.ts b/web-app/src/api/auth.ts index 50c7ef9..3bbcbbc 100644 --- a/web-app/src/api/auth.ts +++ b/web-app/src/api/auth.ts @@ -1,18 +1,34 @@ -import { ApiClient, ApiError, type ApiResponse } from './base' +import { ApiClient } from './base' + +interface UsernameAvailability { + available: boolean +} export class AuthApiClient extends ApiClient { - async login(username: string, password: string): ApiResponse { + async login(username: string, password: string): Promise { return await super.postText('/login', { username, password }) } - async register(username: string, password: string): ApiResponse { - const r = await super.post('/register', { username, password }) - if (r instanceof ApiError) return r + async register(username: string, password: string): Promise { + await super.postJson('/register', { username, password }) } - async getUsernameAvailability(username: string): ApiResponse { - const r = await super.post('/register/username-availability?username=' + username) - if (r instanceof ApiError) return r - return (await r.json()).available + async getUsernameAvailability(username: string): Promise { + const r = (await super.getJson( + '/register/username-availability?username=' + username, + )) as UsernameAvailability + return r.available + } + + async getMyUser(): Promise { + return await super.getText('/me') + } + + async deleteMyUser(): Promise { + await super.delete('/me') + } + + async getNewToken(): Promise { + return await super.getText('/me/token') } } diff --git a/web-app/src/api/base.ts b/web-app/src/api/base.ts index bc8e16e..bdff042 100644 --- a/web-app/src/api/base.ts +++ b/web-app/src/api/base.ts @@ -1,3 +1,6 @@ +import { useAuthStore } from '@/stores/auth-store' +import { toQueryParams, type Page, type PageRequest } from './pagination' + export abstract class ApiError { readonly message: string @@ -17,32 +20,84 @@ export class StatusError extends ApiError { } } -export type ApiResponse = Promise - -export class ApiClient { +export abstract class ApiClient { private baseUrl: string = import.meta.env.VITE_API_BASE_URL - async getJson(path: string): ApiResponse { - const r = await this.get(path) - if (r instanceof ApiError) return r + protected async getJson(path: string): Promise { + const r = await this.doRequest('GET', path) return await r.json() } - async postJson(path: string, body: object | undefined = undefined): ApiResponse { - const r = await this.post(path, body) - if (r instanceof ApiError) return r - return await r.json() + protected async getJsonPage( + path: string, + paginationOptions: PageRequest | undefined = undefined, + ): Promise> { + let p = path + if (paginationOptions !== undefined) { + p += '?' + toQueryParams(paginationOptions) + } + return this.getJson(p) } - async postText(path: string, body: object | undefined = undefined): ApiResponse { - const r = await this.post(path, body) - if (r instanceof ApiError) return r + protected async getText(path: string): Promise { + const r = await this.doRequest('GET', path) return await r.text() } - async get(path: string): Promise { + protected async postJson(path: string, body: object | undefined = undefined): Promise { + const r = await this.doRequest('POST', path, body) + return await r.json() + } + + protected async postText(path: string, body: object | undefined = undefined): Promise { + const r = await this.doRequest('POST', path, body) + return await r.text() + } + + protected async delete(path: string): Promise { + await this.doRequest('DELETE', path) + } + + protected async putJson(path: string, body: object | undefined = undefined): Promise { + const r = await this.doRequest('PUT', path, body) + return await r.json() + } + + async getApiStatus(): Promise { try { - const response = await fetch(this.baseUrl + path) + await this.doRequest('GET', '/status') + return true + } catch { + return false + } + } + + /** + * Does a generic request, returning the response if successful, or throwing an ApiError if + * any sort of error or non-OK status is returned. + * @param method The HTTP method to use. + * @param path The API path to request. + * @param body The request body. + * @returns A promise that resolves to an OK response. + */ + private async doRequest( + method: string, + path: string, + body: object | undefined = undefined, + ): Promise { + const settings: RequestInit = { method } + const headers: HeadersInit = {} + if (body !== undefined && typeof body === 'object') { + headers['Content-Type'] = 'application/json' + settings.body = JSON.stringify(body) + } + const authStore = useAuthStore() + if (authStore.state) { + headers['Authorization'] = 'Bearer ' + authStore.state.token + } + settings.headers = headers + try { + const response = await fetch(this.baseUrl + path, settings) if (!response.ok) { throw new StatusError(response.status, 'Status error') } @@ -52,26 +107,4 @@ export class ApiClient { throw new NetworkError('Request to ' + path + ' failed.') } } - - async post(path: string, body: object | undefined = undefined): Promise { - try { - const response = await fetch(this.baseUrl + path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (!response.ok) { - throw new StatusError(response.status, 'Status error') - } - return response - } catch (error) { - console.error(error) - throw new NetworkError('Request to ' + path + ' failed.') - } - } - - async getApiStatus(): ApiResponse { - const resp = await this.get('/status') - return !(resp instanceof ApiError) - } } diff --git a/web-app/src/api/pagination.ts b/web-app/src/api/pagination.ts new file mode 100644 index 0000000..c4edfb1 --- /dev/null +++ b/web-app/src/api/pagination.ts @@ -0,0 +1,27 @@ +export type SortDir = 'ASC' | 'DESC' + +export interface Sort { + attribute: string + dir: SortDir +} + +export interface PageRequest { + page: number + size: number + sorts: Sort[] +} + +export function toQueryParams(pageRequest: PageRequest): string { + const params = new URLSearchParams() + params.append('page', pageRequest.page + '') + params.append('size', pageRequest.size + '') + for (const sort of pageRequest.sorts) { + params.append('sort', sort.attribute + ',' + sort.dir) + } + return params.toString() +} + +export interface Page { + items: T[] + pageRequest: PageRequest +} diff --git a/web-app/src/api/profile.ts b/web-app/src/api/profile.ts new file mode 100644 index 0000000..2a057e9 --- /dev/null +++ b/web-app/src/api/profile.ts @@ -0,0 +1,28 @@ +import { ApiClient } from './base' + +export interface Profile { + name: string +} + +export interface ProfileProperty { + property: string + value: string +} + +export class ProfileApiClient extends ApiClient { + async getProfiles(): Promise { + return await super.getJson('/profiles') + } + + async createProfile(name: string): Promise { + return await super.postJson('/profiles', { name }) + } + + async deleteProfile(name: string): Promise { + return await super.delete(`/profiles/${name}`) + } + + async getProperties(profileName: string): Promise { + return await super.getJson(`/profiles/${profileName}/properties`) + } +} diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts new file mode 100644 index 0000000..6bfd0c7 --- /dev/null +++ b/web-app/src/api/transaction.ts @@ -0,0 +1,60 @@ +import { ApiClient } from './base' +import { type Page, type PageRequest } from './pagination' +import type { Profile } from './profile' + +export interface TransactionVendor { + id: number + name: string + description: string +} + +export interface TransactionVendorPayload { + name: string + description: string +} + +export interface Transaction { + id: number + timestamp: string + addedAt: string + amount: number + currency: string + description: string + vendorId: number | null + categoryId: number | null +} + +export class TransactionApiClient extends ApiClient { + readonly path: string + + constructor(profile: Profile) { + super() + this.path = `/profiles/${profile.name}` + } + + async getVendors(): Promise { + return await super.getJson(this.path + '/vendors') + } + + async getVendor(id: number): Promise { + return await super.getJson(this.path + '/vendors/' + id) + } + + async createVendor(data: TransactionVendorPayload): Promise { + return await super.postJson(this.path + '/vendors', data) + } + + async updateVendor(id: number, data: TransactionVendorPayload): Promise { + return await super.putJson(this.path + '/vendors/' + id, data) + } + + async deleteVendor(id: number): Promise { + return await super.delete(this.path + '/vendors/' + id) + } + + async getTransactions( + paginationOptions: PageRequest | undefined = undefined, + ): Promise> { + return await super.getJsonPage(this.path + '/transactions', paginationOptions) + } +} diff --git a/web-app/src/pages/HomePage.vue b/web-app/src/pages/HomePage.vue new file mode 100644 index 0000000..f09e275 --- /dev/null +++ b/web-app/src/pages/HomePage.vue @@ -0,0 +1,72 @@ + + diff --git a/web-app/src/pages/LoginPage.vue b/web-app/src/pages/LoginPage.vue new file mode 100644 index 0000000..20fad7d --- /dev/null +++ b/web-app/src/pages/LoginPage.vue @@ -0,0 +1,50 @@ + + diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts index e1eab52..1ea6394 100644 --- a/web-app/src/router/index.ts +++ b/web-app/src/router/index.ts @@ -1,8 +1,28 @@ -import { createRouter, createWebHistory } from 'vue-router' +import HomePage from '@/pages/HomePage.vue' +import LoginPage from '@/pages/LoginPage.vue' +import { useAuthStore } from '@/stores/auth-store' +import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [], + routes: [ + { + path: '/login', + component: async () => LoginPage, + }, + { + path: '/', + component: async () => HomePage, + beforeEnter: onlyAuthenticated, + }, + ], }) +export function onlyAuthenticated(to: RouteLocationNormalized) { + const authStore = useAuthStore() + if (authStore.state) return true + if (to.path === '/') return '/login' + return '/login?next=' + encodeURIComponent(to.path) +} + export default router diff --git a/web-app/src/stores/auth-store.ts b/web-app/src/stores/auth-store.ts new file mode 100644 index 0000000..c7e4d06 --- /dev/null +++ b/web-app/src/stores/auth-store.ts @@ -0,0 +1,21 @@ +import { defineStore } from 'pinia' +import { ref, type Ref } from 'vue' + +export interface AuthenticatedData { + username: string + token: string +} + +export const useAuthStore = defineStore('auth', () => { + const state: Ref = ref(null) + + function onUserLoggedIn(username: string, token: string) { + state.value = { username, token } + } + + function onUserLoggedOut() { + state.value = null + } + + return { state, onUserLoggedIn, onUserLoggedOut } +}) diff --git a/web-app/src/stores/counter.ts b/web-app/src/stores/counter.ts deleted file mode 100644 index b6757ba..0000000 --- a/web-app/src/stores/counter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ref, computed } from 'vue' -import { defineStore } from 'pinia' - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0) - const doubleCount = computed(() => count.value * 2) - function increment() { - count.value++ - } - - return { count, doubleCount, increment } -})