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.
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/finn.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Finnow</title>
|
||||
</head>
|
||||
<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">
|
||||
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>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<h1>You did it!</h1>
|
||||
<p>
|
||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
||||
documentation
|
||||
</p>
|
||||
<RouterView></RouterView>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
async login(username: string, password: string): ApiResponse<string> {
|
||||
async login(username: string, password: string): Promise<string> {
|
||||
return await super.postText('/login', { username, password })
|
||||
}
|
||||
|
||||
async register(username: string, password: string): ApiResponse<void> {
|
||||
const r = await super.post('/register', { username, password })
|
||||
if (r instanceof ApiError) return r
|
||||
async register(username: string, password: string): Promise<void> {
|
||||
await super.postJson('/register', { username, password })
|
||||
}
|
||||
|
||||
async getUsernameAvailability(username: string): ApiResponse<boolean> {
|
||||
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<boolean> {
|
||||
const r = (await super.getJson(
|
||||
'/register/username-availability?username=' + username,
|
||||
)) 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 {
|
||||
readonly message: string
|
||||
|
||||
|
@ -17,32 +20,84 @@ export class StatusError extends ApiError {
|
|||
}
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = Promise<T | ApiError>
|
||||
|
||||
export class ApiClient {
|
||||
export abstract class ApiClient {
|
||||
private baseUrl: string = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
async getJson<R>(path: string): ApiResponse<R> {
|
||||
const r = await this.get(path)
|
||||
if (r instanceof ApiError) return r
|
||||
protected async getJson<T>(path: string): Promise<T> {
|
||||
const r = await this.doRequest('GET', path)
|
||||
return await r.json()
|
||||
}
|
||||
|
||||
async postJson<R>(path: string, body: object | undefined = undefined): ApiResponse<R> {
|
||||
const r = await this.post(path, body)
|
||||
if (r instanceof ApiError) return r
|
||||
return await r.json()
|
||||
protected async getJsonPage<T>(
|
||||
path: string,
|
||||
paginationOptions: PageRequest | undefined = undefined,
|
||||
): 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> {
|
||||
const r = await this.post(path, body)
|
||||
if (r instanceof ApiError) return r
|
||||
protected async getText(path: string): Promise<string> {
|
||||
const r = await this.doRequest('GET', path)
|
||||
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 {
|
||||
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) {
|
||||
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<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({
|
||||
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
|
||||
|
|
|
@ -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