Added icon, better styling, and main pages for web app.
This commit is contained in:
parent
aa6ec75b54
commit
1f78983038
|
@ -40,6 +40,7 @@ HttpRequestHandler mapApiHandlers() {
|
||||||
a.map(HttpMethod.POST, "/profiles", &handleCreateNewProfile);
|
a.map(HttpMethod.POST, "/profiles", &handleCreateNewProfile);
|
||||||
/// URL path to a specific profile, with the :profile path parameter.
|
/// URL path to a specific profile, with the :profile path parameter.
|
||||||
const PROFILE_PATH = "/profiles/:profile";
|
const PROFILE_PATH = "/profiles/:profile";
|
||||||
|
a.map(HttpMethod.GET, PROFILE_PATH, &handleGetProfile);
|
||||||
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
|
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
|
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,11 @@ void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse res
|
||||||
writeJsonBody(response, profiles);
|
writeJsonBody(response, profiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleGetProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileContext profileCtx = getProfileContextOrThrow(request);
|
||||||
|
writeJsonBody(response, profileCtx.profile);
|
||||||
|
}
|
||||||
|
|
||||||
void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
string name = request.getPathParamAs!string("profile");
|
string name = request.getPathParamAs!string("profile");
|
||||||
if (!validateProfileName(name)) {
|
if (!validateProfileName(name)) {
|
||||||
|
|
|
@ -8,6 +8,10 @@
|
||||||
"name": "web-app",
|
"name": "web-app",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||||
|
"@fortawesome/vue-fontawesome": "^3.1.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
|
@ -1150,6 +1154,61 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-PGMrIYXLGA5K8RWy8zwBkd4vFi4z7ubxtet6Yn13Plf6krRTwPbdlCwlcfmoX0R7B4Z643QvrtHmdQ5fNtfFCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-obBEF+zd98r/KtKVW6A+8UGWeaOoyMpl6Q9P3FzHsOnsg742aXsl8v+H/zp09qSSu/a/Hxe9LNKzbBaQq1CEbA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/free-regular-svg-icons": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-qAh0mTaCY22sQzMK2lKBrtn/aR4keUu5XmtdYR7d702laMe0h+Ab4Kj2pExR9HZkKhjKoq8pbwt8Td+mjW/ipQ==",
|
||||||
|
"license": "(CC-BY-4.0 AND MIT)",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-njSLAllkOddYDCXgTFboXn54Oe5FcvpkWq+FoetOHR64PbN0608kM02Lze0xtISGpXgP+i26VyXRQA0Irh3Obw==",
|
||||||
|
"license": "(CC-BY-4.0 AND MIT)",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/vue-fontawesome": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-U5azn4mcUVpjHe4JO0Wbe7Ih8e3VbN83EH7OTBtA5/QGw9qcPGffqcmwsLyZYgEkpVkYbq/6dX1Iyl5KUGMp6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7",
|
||||||
|
"vue": ">= 3.0.0 < 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
|
|
|
@ -16,6 +16,10 @@
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||||
|
"@fortawesome/vue-fontawesome": "^3.1.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
|
|
|
@ -14,6 +14,10 @@ export class ProfileApiClient extends ApiClient {
|
||||||
return await super.getJson('/profiles')
|
return await super.getJson('/profiles')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProfile(name: string): Promise<Profile> {
|
||||||
|
return await super.getJson('/profiles/' + name)
|
||||||
|
}
|
||||||
|
|
||||||
async createProfile(name: string): Promise<Profile> {
|
async createProfile(name: string): Promise<Profile> {
|
||||||
return await super.postJson('/profiles', { name })
|
return await super.postJson('/profiles', { name })
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondsUntilExpired(token: string): number {
|
||||||
|
const exp = parseExpiration(token)
|
||||||
|
const now = Date.now() / 1000
|
||||||
|
return exp - now
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExpired(token: string): boolean {
|
||||||
|
return secondsUntilExpired(token) <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubject(token: string): string {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return ''
|
||||||
|
const payload = JSON.parse(atob(parts[1]))
|
||||||
|
return payload.sub
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,93 @@
|
||||||
|
Copyright 2023 The Playwrite Project Authors (https://github.com/TypeTogether/Playwrite)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
Binary file not shown.
|
@ -0,0 +1,61 @@
|
||||||
|
:root {
|
||||||
|
--theme-primary: #01bbff;
|
||||||
|
--theme-secondary: #00759f;
|
||||||
|
--theme-tertiary: #01ffc4;
|
||||||
|
--bg-primary: #00131a;
|
||||||
|
--bg-secondary: #003447;
|
||||||
|
--bg-page: #000b0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'OpenSans';
|
||||||
|
src: url('fonts/open-sans/OpenSans-VariableFont_wdth,wght.ttf');
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'OpenSans';
|
||||||
|
src: url('fonts/open-sans/OpenSans-Italic-VariableFont_wdth,wght.ttf');
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'PlaywriteNL';
|
||||||
|
src: url('fonts/playwrite-nl/PlaywriteNL-VariableFont_wght.ttf');
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'OpenSans', sans-serif;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: var(--theme-tertiary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 0.5em;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
background-color: var(--bg-page);
|
||||||
|
|
||||||
|
border-bottom-left-radius: 2em;
|
||||||
|
border-bottom-right-radius: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
|
@ -1,12 +1,19 @@
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
|
import { fas } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { far } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
library.add(fas, far)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.component('font-awesome-icon', FontAwesomeIcon)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
|
@ -1,72 +0,0 @@
|
||||||
<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,51 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AccountApiClient, type Account } from '@/api/account';
|
||||||
|
import { ProfileApiClient, type Profile } from '@/api/profile';
|
||||||
|
import { onMounted, ref, type Ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const profile: Ref<Profile | undefined> = ref(undefined)
|
||||||
|
const accounts: Ref<Account[]> = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const profileName = route.params.name
|
||||||
|
if (Array.isArray(profileName)) {
|
||||||
|
await router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const api = new ProfileApiClient()
|
||||||
|
profile.value = await api.getProfile(profileName)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
await router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = new AccountApiClient(profile.value)
|
||||||
|
accounts.value = await api.getAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load accounts', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="app-page-container">
|
||||||
|
<h1 class="app-page-title">Profile {{ profile?.name }}</h1>
|
||||||
|
<p>
|
||||||
|
This is the page for the profile!
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="account in accounts" :key="account.id">
|
||||||
|
<p>
|
||||||
|
Account {{ account.id }} for currency {{ account.currency }}
|
||||||
|
Number suffix: {{ account.numberSuffix }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,51 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ProfileApiClient, type Profile } from '@/api/profile';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { onMounted, type Ref, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const profiles: Ref<Profile[]> = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
authStore.$subscribe(async (_, state) => {
|
||||||
|
if (state.state === null) {
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = new ProfileApiClient()
|
||||||
|
try {
|
||||||
|
profiles.value = (await client.getProfiles()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
} catch {
|
||||||
|
profiles.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="app-page-container">
|
||||||
|
<h1 class="app-page-title">Profiles</h1>
|
||||||
|
|
||||||
|
<div class="profile-card" v-for="profile in profiles" :key="profile.name"
|
||||||
|
@click="router.push('/profiles/' + profile.name)">
|
||||||
|
<span>{{ profile.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="css">
|
||||||
|
.profile-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5em;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-radius: 1em;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card:hover {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,121 @@
|
||||||
|
<!--
|
||||||
|
The shell layout for all user-authenticated parts of the app. It defines a top
|
||||||
|
bar with some controls and user information, and a router view for all child
|
||||||
|
pages.
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AuthApiClient } from '@/api/auth';
|
||||||
|
import { secondsUntilExpired } from '@/api/token-util';
|
||||||
|
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)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
authStore.$subscribe(async (_, state) => {
|
||||||
|
if (state.state === null) {
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
authCheckTimer.value = setInterval(checkAuth, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
if (!authStore.state) {
|
||||||
|
clearInterval(authCheckTimer.value)
|
||||||
|
authCheckTimer.value = undefined
|
||||||
|
await router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsUntilExpiration = secondsUntilExpired(authStore.state.token)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<header class="app-header-bar">
|
||||||
|
<div>
|
||||||
|
<h1 class="app-header-text" @click="router.push('/')">Finnow</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="app-user-widget">
|
||||||
|
Welcome, <em>{{ authStore.state?.username }}</em>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="app-logout-button" @click="authStore.onUserLoggedOut()">
|
||||||
|
Log out
|
||||||
|
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RouterView :key="$route.fullPath"></RouterView>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css">
|
||||||
|
.app-header-bar {
|
||||||
|
background-color: var(--theme-primary);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-text {
|
||||||
|
display: inline;
|
||||||
|
margin: 0 1em 0 1em;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'PlaywriteNL';
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-text:hover {
|
||||||
|
color: var(--theme-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logout-button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logout-button:hover {
|
||||||
|
color: var(--bg-secondary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-widget {
|
||||||
|
margin-right: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-link {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--bg-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 0.5em 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-link:hover {
|
||||||
|
color: var(--bg-secondary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="app-page-container">
|
||||||
|
<h1 class="app-page-title">Homepage</h1>
|
||||||
|
<p>This is your user's homepage.</p>
|
||||||
|
<RouterLink to="/profiles">
|
||||||
|
<font-awesome-icon icon="fa-folder-open"></font-awesome-icon>
|
||||||
|
View your profiles
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,7 +1,10 @@
|
||||||
import HomePage from '@/pages/HomePage.vue'
|
import UserAccountLayout from '@/pages/UserAccountLayout.vue'
|
||||||
import LoginPage from '@/pages/LoginPage.vue'
|
import LoginPage from '@/pages/LoginPage.vue'
|
||||||
|
import ProfilePage from '@/pages/ProfilePage.vue'
|
||||||
import { useAuthStore } from '@/stores/auth-store'
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||||
|
import UserHomePage from '@/pages/UserHomePage.vue'
|
||||||
|
import ProfilesPage from '@/pages/ProfilesPage.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -9,16 +12,45 @@ const router = createRouter({
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
component: async () => LoginPage,
|
component: async () => LoginPage,
|
||||||
|
meta: { title: 'Login' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: async () => HomePage,
|
component: async () => UserAccountLayout,
|
||||||
beforeEnter: onlyAuthenticated,
|
beforeEnter: onlyAuthenticated,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: async () => UserHomePage,
|
||||||
|
meta: { title: 'Home' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'profiles',
|
||||||
|
component: async () => ProfilesPage,
|
||||||
|
meta: { title: 'Profiles' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profiles/:name',
|
||||||
|
component: async () => ProfilePage,
|
||||||
|
meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
export function onlyAuthenticated(to: RouteLocationNormalized) {
|
router.beforeEach((to, _, next) => {
|
||||||
|
if (to.meta.title !== undefined && typeof to.meta.title === 'string') {
|
||||||
|
document.title = 'Finnow - ' + to.meta.title
|
||||||
|
} else if (to.meta.title !== undefined && typeof to.meta.title === 'function') {
|
||||||
|
document.title = 'Finnow - ' + to.meta.title(to)
|
||||||
|
} else {
|
||||||
|
document.title = 'Finnow'
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
function onlyAuthenticated(to: RouteLocationNormalized) {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (authStore.state) return true
|
if (authStore.state) return true
|
||||||
if (to.path === '/') return '/login'
|
if (to.path === '/') return '/login'
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { isExpired, parseSubject } from '@/api/token-util'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
@ -7,15 +8,28 @@ export interface AuthenticatedData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const state: Ref<AuthenticatedData | null> = ref(null)
|
const state: Ref<AuthenticatedData | null> = ref(getStateFromLocalStorage())
|
||||||
|
|
||||||
function onUserLoggedIn(username: string, token: string) {
|
function onUserLoggedIn(username: string, token: string) {
|
||||||
state.value = { username, token }
|
state.value = { username, token }
|
||||||
|
localStorage.setItem('token', token)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUserLoggedOut() {
|
function onUserLoggedOut() {
|
||||||
state.value = null
|
state.value = null
|
||||||
|
localStorage.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state, onUserLoggedIn, onUserLoggedOut }
|
return { state, onUserLoggedIn, onUserLoggedOut }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getStateFromLocalStorage(): AuthenticatedData | null {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token === null || token.length === 0 || isExpired(token)) {
|
||||||
|
localStorage.clear()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const username = parseSubject(token)
|
||||||
|
console.info('Loaded authentication information for user', username)
|
||||||
|
return { username, token }
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue