Added remaining API clients, Finn icon.
This commit is contained in:
parent
d610e70b18
commit
aa6ec75b54
|
@ -0,0 +1,84 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="256"
|
||||||
|
height="256"
|
||||||
|
viewBox="0 0 67.733332 67.733333"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||||
|
sodipodi:docname="finn.svg"
|
||||||
|
inkscape:export-filename="finn.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#ffffff"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.2047486"
|
||||||
|
inkscape:cx="90.03294"
|
||||||
|
inkscape:cy="148.54301"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1021"
|
||||||
|
inkscape:window-x="1080"
|
||||||
|
inkscape:window-y="475"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs2" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<ellipse
|
||||||
|
style="font-variation-settings:'wght' 700;fill:#00759f;fill-opacity:1;stroke:none;stroke-width:8.74738;stroke-linecap:round;stroke-dasharray:none"
|
||||||
|
id="circle1478"
|
||||||
|
cx="27.757578"
|
||||||
|
cy="33.714817"
|
||||||
|
rx="26.103556"
|
||||||
|
ry="21.226439" />
|
||||||
|
<path
|
||||||
|
style="font-variation-settings:'wght' 700;fill:#00759f;fill-opacity:1;stroke:none;stroke-width:8.7474;stroke-linecap:round;stroke-dasharray:none"
|
||||||
|
d="M 67.344548,12.488382 V 53.396 L 40.956176,31.937954 Z"
|
||||||
|
id="path1480" />
|
||||||
|
<circle
|
||||||
|
style="font-variation-settings:'wght' 700;fill:#01bbff;fill-opacity:1;stroke:none;stroke-width:9.21621;stroke-linecap:round;stroke-dasharray:none"
|
||||||
|
id="path234"
|
||||||
|
cx="25.18939"
|
||||||
|
cy="33.866665"
|
||||||
|
r="24.800606" />
|
||||||
|
<path
|
||||||
|
style="font-variation-settings:'wght' 700;fill:#01bbff;fill-opacity:1;stroke:none;stroke-width:9.21622;stroke-linecap:round;stroke-dasharray:none"
|
||||||
|
d="M 62.800391,9.066063 V 56.861819 L 37.729185,31.790608 Z"
|
||||||
|
id="path952" />
|
||||||
|
<circle
|
||||||
|
style="font-variation-settings:'wght' 700;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:14.7857;stroke-linecap:round;stroke-dasharray:none"
|
||||||
|
id="path1006"
|
||||||
|
cx="12.466989"
|
||||||
|
cy="25.046286"
|
||||||
|
r="6.9078312" />
|
||||||
|
<circle
|
||||||
|
style="font-variation-settings:'wght' 700;fill:#000000;fill-opacity:1;stroke:none;stroke-width:10.006;stroke-linecap:round;stroke-dasharray:none"
|
||||||
|
id="circle1476"
|
||||||
|
cx="10.739256"
|
||||||
|
cy="23.665251"
|
||||||
|
r="4.6747308" />
|
||||||
|
<path
|
||||||
|
style="font-variation-settings:'wght' 700;fill:#00759f;fill-opacity:1;stroke:none;stroke-width:13.6185;stroke-linecap:round;stroke-dasharray:none"
|
||||||
|
d="m 15.98084,47.161772 c 4.005313,7.206705 30.289338,-2.16964 26.417411,-7.54042 -3.871927,-5.370784 -8.650814,3.336065 -8.650814,3.336065 0,0 6.952397,-5.596817 4.225869,-8.984893 -2.72653,-3.388076 -8.937045,1.913847 -8.937045,1.913847 0,0 7.941211,-2.726489 4.649701,-6.84423 -3.291513,-4.11774 -21.710435,10.912926 -17.705122,18.119631 z"
|
||||||
|
id="path1812"
|
||||||
|
sodipodi:nodetypes="zzczczz" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.4 KiB |
|
@ -79,12 +79,6 @@ private void getOptions(ref ServerHttpRequest request, ref ServerHttpResponse re
|
||||||
// Do nothing, just return 200 OK.
|
// 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) {
|
private void sampleDataEndpoint(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import util.sample_data;
|
import util.sample_data;
|
||||||
|
@ -117,9 +111,14 @@ private class CorsHandler : HttpRequestHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
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-Methods", "*");
|
||||||
response.headers.add("Access-Control-Allow-Headers", "*");
|
response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
||||||
this.handler.handle(request, response);
|
try {
|
||||||
|
this.handler.handle(request, response);
|
||||||
|
} catch (HttpStatusException e) {
|
||||||
|
response.status = e.status;
|
||||||
|
response.writeBodyString(e.message.idup);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,6 @@ import handy_http_handlers.filtered_handler;
|
||||||
import auth.model;
|
import auth.model;
|
||||||
import auth.data;
|
import auth.data;
|
||||||
import auth.data_impl_fs;
|
import auth.data_impl_fs;
|
||||||
import auth.tokens;
|
|
||||||
|
|
||||||
const ubyte[] PASSWORD_HASH_PEPPER = []; // Example pepper for password hashing
|
const ubyte[] PASSWORD_HASH_PEPPER = []; // Example pepper for password hashing
|
||||||
|
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -27,7 +27,8 @@ void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpRespons
|
||||||
}
|
}
|
||||||
AuthContext auth = getAuthContext(request);
|
AuthContext auth = getAuthContext(request);
|
||||||
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
|
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) {
|
void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
|
|
@ -11,14 +11,40 @@ import profile.data;
|
||||||
import profile.service;
|
import profile.service;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
|
import util.data;
|
||||||
|
|
||||||
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(0, 10, [Sort("created_at", SortDir.DESC)]);
|
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) {
|
void getTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
|
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
|
||||||
Page!Transaction page = ds.getTransactionRepository().findAll(pr);
|
Page!Transaction page = ds.getTransactionRepository().findAll(pr);
|
||||||
writeJsonBody(response, page);
|
writeJsonBody(response, page.mapItems(&TransactionResponse.of));
|
||||||
}
|
}
|
||||||
|
|
||||||
void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
|
|
@ -98,3 +98,12 @@ struct Page(T) {
|
||||||
T[] items;
|
T[] items;
|
||||||
PageRequest pageRequest;
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" type="image/png" href="/finn.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Finnow</title>
|
<title>Finnow</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
|
@ -1,23 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts"></script>
|
||||||
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)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>You did it!</h1>
|
<RouterView></RouterView>
|
||||||
<p>
|
|
||||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
|
||||||
documentation
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
@ -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<Account[]> {
|
||||||
|
return super.getJson(this.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccount(id: number): Promise<Account> {
|
||||||
|
return super.getJson(this.path + '/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(data: AccountCreationPayload): Promise<Account> {
|
||||||
|
return super.postJson(this.path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(id: number): Promise<void> {
|
||||||
|
return super.delete(this.path + '/' + id)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,18 +1,34 @@
|
||||||
import { ApiClient, ApiError, type ApiResponse } from './base'
|
import { ApiClient } from './base'
|
||||||
|
|
||||||
|
interface UsernameAvailability {
|
||||||
|
available: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export class AuthApiClient extends ApiClient {
|
export class AuthApiClient extends ApiClient {
|
||||||
async login(username: string, password: string): ApiResponse<string> {
|
async login(username: string, password: string): Promise<string> {
|
||||||
return await super.postText('/login', { username, password })
|
return await super.postText('/login', { username, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(username: string, password: string): ApiResponse<void> {
|
async register(username: string, password: string): Promise<void> {
|
||||||
const r = await super.post('/register', { username, password })
|
await super.postJson('/register', { username, password })
|
||||||
if (r instanceof ApiError) return r
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsernameAvailability(username: string): ApiResponse<boolean> {
|
async getUsernameAvailability(username: string): Promise<boolean> {
|
||||||
const r = await super.post('/register/username-availability?username=' + username)
|
const r = (await super.getJson(
|
||||||
if (r instanceof ApiError) return r
|
'/register/username-availability?username=' + username,
|
||||||
return (await r.json()).available
|
)) as UsernameAvailability
|
||||||
|
return r.available
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyUser(): Promise<string> {
|
||||||
|
return await super.getText('/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyUser(): Promise<void> {
|
||||||
|
await super.delete('/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNewToken(): Promise<string> {
|
||||||
|
return await super.getText('/me/token')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
|
import { toQueryParams, type Page, type PageRequest } from './pagination'
|
||||||
|
|
||||||
export abstract class ApiError {
|
export abstract class ApiError {
|
||||||
readonly message: string
|
readonly message: string
|
||||||
|
|
||||||
|
@ -17,32 +20,84 @@ export class StatusError extends ApiError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiResponse<T> = Promise<T | ApiError>
|
export abstract class ApiClient {
|
||||||
|
|
||||||
export class ApiClient {
|
|
||||||
private baseUrl: string = import.meta.env.VITE_API_BASE_URL
|
private baseUrl: string = import.meta.env.VITE_API_BASE_URL
|
||||||
|
|
||||||
async getJson<R>(path: string): ApiResponse<R> {
|
protected async getJson<T>(path: string): Promise<T> {
|
||||||
const r = await this.get(path)
|
const r = await this.doRequest('GET', path)
|
||||||
if (r instanceof ApiError) return r
|
|
||||||
return await r.json()
|
return await r.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
async postJson<R>(path: string, body: object | undefined = undefined): ApiResponse<R> {
|
protected async getJsonPage<T>(
|
||||||
const r = await this.post(path, body)
|
path: string,
|
||||||
if (r instanceof ApiError) return r
|
paginationOptions: PageRequest | undefined = undefined,
|
||||||
return await r.json()
|
): Promise<Page<T>> {
|
||||||
|
let p = path
|
||||||
|
if (paginationOptions !== undefined) {
|
||||||
|
p += '?' + toQueryParams(paginationOptions)
|
||||||
|
}
|
||||||
|
return this.getJson(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
async postText(path: string, body: object | undefined = undefined): ApiResponse<string> {
|
protected async getText(path: string): Promise<string> {
|
||||||
const r = await this.post(path, body)
|
const r = await this.doRequest('GET', path)
|
||||||
if (r instanceof ApiError) return r
|
|
||||||
return await r.text()
|
return await r.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(path: string): Promise<Response | ApiError> {
|
protected async postJson<T>(path: string, body: object | undefined = undefined): Promise<T> {
|
||||||
|
const r = await this.doRequest('POST', path, body)
|
||||||
|
return await r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postText(path: string, body: object | undefined = undefined): Promise<string> {
|
||||||
|
const r = await this.doRequest('POST', path, body)
|
||||||
|
return await r.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async delete(path: string): Promise<void> {
|
||||||
|
await this.doRequest('DELETE', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async putJson<T>(path: string, body: object | undefined = undefined): Promise<T> {
|
||||||
|
const r = await this.doRequest('PUT', path, body)
|
||||||
|
return await r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApiStatus(): Promise<boolean> {
|
||||||
try {
|
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<Response> {
|
||||||
|
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) {
|
if (!response.ok) {
|
||||||
throw new StatusError(response.status, 'Status error')
|
throw new StatusError(response.status, 'Status error')
|
||||||
}
|
}
|
||||||
|
@ -52,26 +107,4 @@ export class ApiClient {
|
||||||
throw new NetworkError('Request to ' + path + ' failed.')
|
throw new NetworkError('Request to ' + path + ' failed.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async post(path: string, body: object | undefined = undefined): Promise<Response | ApiError> {
|
|
||||||
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<boolean> {
|
|
||||||
const resp = await this.get('/status')
|
|
||||||
return !(resp instanceof ApiError)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<T> {
|
||||||
|
items: T[]
|
||||||
|
pageRequest: PageRequest
|
||||||
|
}
|
|
@ -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<Profile[]> {
|
||||||
|
return await super.getJson('/profiles')
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProfile(name: string): Promise<Profile> {
|
||||||
|
return await super.postJson('/profiles', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProfile(name: string): Promise<void> {
|
||||||
|
return await super.delete(`/profiles/${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProperties(profileName: string): Promise<ProfileProperty[]> {
|
||||||
|
return await super.getJson(`/profiles/${profileName}/properties`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<TransactionVendor[]> {
|
||||||
|
return await super.getJson(this.path + '/vendors')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVendor(id: number): Promise<TransactionVendor> {
|
||||||
|
return await super.getJson(this.path + '/vendors/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVendor(data: TransactionVendorPayload): Promise<TransactionVendor> {
|
||||||
|
return await super.postJson(this.path + '/vendors', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVendor(id: number, data: TransactionVendorPayload): Promise<TransactionVendor> {
|
||||||
|
return await super.putJson(this.path + '/vendors/' + id, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVendor(id: number): Promise<void> {
|
||||||
|
return await super.delete(this.path + '/vendors/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactions(
|
||||||
|
paginationOptions: PageRequest | undefined = undefined,
|
||||||
|
): Promise<Page<Transaction>> {
|
||||||
|
return await super.getJsonPage(this.path + '/transactions', paginationOptions)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AuthApiClient } from '@/api/auth';
|
||||||
|
import { ProfileApiClient, type Profile } from '@/api/profile';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { onMounted, ref, type Ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const authCheckTimer: Ref<number | undefined> = ref(undefined)
|
||||||
|
const profiles: Ref<Profile[]> = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
authStore.$subscribe(async (_, state) => {
|
||||||
|
if (state.state === null) {
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
authCheckTimer.value = setInterval(checkAuth, 1000)
|
||||||
|
|
||||||
|
const client = new ProfileApiClient()
|
||||||
|
try {
|
||||||
|
profiles.value = (await client.getProfiles()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
} catch {
|
||||||
|
profiles.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
if (!authStore.state) {
|
||||||
|
clearInterval(authCheckTimer.value)
|
||||||
|
authCheckTimer.value = undefined
|
||||||
|
await router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exp = parseExpiration(authStore.state.token)
|
||||||
|
const now = Date.now() / 1000
|
||||||
|
const secondsUntilExpiration = exp - now
|
||||||
|
if (secondsUntilExpiration < 60 && secondsUntilExpiration > 5) {
|
||||||
|
const api = new AuthApiClient()
|
||||||
|
try {
|
||||||
|
const newToken = await api.getNewToken()
|
||||||
|
authStore.state.token = newToken
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to refresh token.', err)
|
||||||
|
}
|
||||||
|
} else if (secondsUntilExpiration <= 0) {
|
||||||
|
authStore.onUserLoggedOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExpiration(token: string): number {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return 0
|
||||||
|
const payload = JSON.parse(atob(parts[1]))
|
||||||
|
return payload.exp
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Home Page</h1>
|
||||||
|
<p>Welcome to your home page, {{ authStore.state?.username }}!</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="profile in profiles" :key="profile.name">
|
||||||
|
<RouterLink :to="`/profiles/${profile.name}`">{{ profile.name }}</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button type="button" @click="authStore.onUserLoggedOut()">Log Out</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,50 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AuthApiClient } from '@/api/auth';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const apiClient = new AuthApiClient()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const disableForm = ref(false)
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
disableForm.value = true
|
||||||
|
try {
|
||||||
|
const token = await apiClient.login(username.value, password.value)
|
||||||
|
authStore.onUserLoggedIn(username.value, token)
|
||||||
|
if ('next' in route.query && typeof (route.query.next) === 'string') {
|
||||||
|
await router.replace(route.query.next)
|
||||||
|
} else {
|
||||||
|
await router.replace('/')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err)
|
||||||
|
} finally {
|
||||||
|
disableForm.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form @submit.prevent="doLogin()">
|
||||||
|
<div>
|
||||||
|
<label for="username-input">Username</label>
|
||||||
|
<input id="username-input" type="text" v-model="username" :disabled="disableForm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password-input">Password</label>
|
||||||
|
<input id="password-input" type="password" v-model="password" :disabled="disableForm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" :disabled="disableForm">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -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({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
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
|
export default router
|
||||||
|
|
|
@ -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<AuthenticatedData | null> = ref(null)
|
||||||
|
|
||||||
|
function onUserLoggedIn(username: string, token: string) {
|
||||||
|
state.value = { username, token }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserLoggedOut() {
|
||||||
|
state.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, onUserLoggedIn, onUserLoggedOut }
|
||||||
|
})
|
|
@ -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 }
|
|
||||||
})
|
|
Loading…
Reference in New Issue