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 df36fcf..0000000 Binary files a/web-app/public/favicon.ico and /dev/null differ diff --git a/web-app/public/finn.png b/web-app/public/finn.png new file mode 100644 index 0000000..058adde Binary files /dev/null and b/web-app/public/finn.png differ diff --git a/web-app/src/App.vue b/web-app/src/App.vue index 6870234..3a6c5ed 100644 --- a/web-app/src/App.vue +++ b/web-app/src/App.vue @@ -1,23 +1,5 @@ - - + - 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 } -})