Added remaining API clients, Finn icon.

This commit is contained in:
Andrew Lalis 2025-08-02 10:17:44 -04:00
parent d610e70b18
commit aa6ec75b54
22 changed files with 557 additions and 197 deletions

84
finn.svg Normal file
View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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);
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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
);
}

View File

@ -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

BIN
web-app/public/finn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -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>

View File

@ -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)
}
}

View File

@ -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')
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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`)
}
}

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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 }
})

View File

@ -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 }
})