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 @@
-
-
+
- You did it!
-
- Visit vuejs.org to read the
- documentation
-
+
-
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 @@
+
+
+
+
Home Page
+
Welcome to your home page, {{ authStore.state?.username }}!
+
+ -
+ {{ profile.name }}
+
+
+
+
+
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 }
-})