Support for users and authentication [frontend].

This commit is contained in:
Fabio Manganiello 2025-03-06 00:42:54 +01:00
parent c4e0c67e34
commit 99de342af3
Signed by: blacklight
GPG key ID: D90FBA7F76362774
28 changed files with 952 additions and 71 deletions

View file

@ -13,10 +13,12 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@vueuse/core": "^12.8.2",
"chart.js": "^4.4.8",
"chartjs-adapter-date-fns": "^3.0.0",
"countries-list": "^3.1.1",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"ol": "^10.4.0",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
@ -1767,6 +1769,12 @@
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.24.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz",
@ -2319,6 +2327,42 @@
}
}
},
"node_modules/@vueuse/core": {
"version": "12.8.2",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz",
"integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "12.8.2",
"@vueuse/shared": "12.8.2",
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/metadata": {
"version": "12.8.2",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz",
"integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "12.8.2",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
"license": "MIT",
"dependencies": {
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@ -3937,7 +3981,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/mrmime": {

View file

@ -18,10 +18,12 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@vueuse/core": "^12.8.2",
"chart.js": "^4.4.8",
"chartjs-adapter-date-fns": "^3.0.0",
"countries-list": "^3.1.1",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"ol": "^10.4.0",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2",

View file

@ -1,24 +1,169 @@
<template>
<!--
<header>
<div class="wrapper">
<nav>
<RouterLink to="/">Home</RouterLink>
</nav>
</div>
</header>
-->
<div class="app-container">
<header v-if="user">
<div class="wrapper">
<nav>
<li class="main">
<RouterLink to="/">
<font-awesome-icon icon="map-marker-alt" />&nbsp;&nbsp;GPSTracker
</RouterLink>
</li>
<RouterView />
<div class="spacer" />
<li class="right">
<RouterLink to="/logout">
<font-awesome-icon icon="sign-out-alt" />&nbsp;&nbsp;
<span class="logout-text">Logout</span>
</RouterLink>
</li>
</nav>
</div>
</header>
<div class="body">
<Loading v-if="loading" />
<RouterView />
</div>
<Messages />
</div>
</template>
<script lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { type Optional } from './models/Types';
import Api from './mixins/Api.vue';
import Loading from './elements/Loading.vue';
import Messages from './components/Messages.vue'
import User from './models/User';
export default {
mixins: [Api],
components: {
Loading,
Messages,
RouterLink,
RouterView,
},
data() {
return {
loading: false,
user: null as Optional<User>,
}
},
async mounted() {
this.loading = true
try {
const auth = await this.fetchUser()
const currentRedirect = this.$route.query.redirect
this.user = auth?.user
if (auth) {
if (currentRedirect?.length) {
this.$router.push(currentRedirect as string)
}
} else {
let redirect = '/login'
if (currentRedirect?.length && currentRedirect !== '/login') {
redirect += `?redirect=${currentRedirect}`
} else if (this.$route.path !== '/login' && this.$route.path !== '/logout') {
redirect += `?redirect=${this.$route.path}${this.$route.hash}`
} else {
redirect += '?redirect=/'
}
this.$router.push(redirect)
}
} finally {
this.loading = false
}
},
}
</script>
<style lang="scss">
@use "@/styles/common.scss" as *;
</style>
<style lang="scss" scoped>
@use "@/styles/common.scss" as *;
$header-height: 3rem;
.app-container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-background);
header {
width: 100%;
height: $header-height;
margin-bottom: 0.2rem;
padding: 0.5rem 0;
box-shadow: 0 0 0.5rem 0 rgba(0, 0, 0, 0.5);
nav {
display: flex;
align-items: center;
li {
display: inline-block;
list-style: none;
border-radius: 0.25rem;
&.main {
font-size: 1.25rem;
margin-left: 0.5rem;
:deep(a) {
&:hover {
background: none;
font-size: 1.5rem;
}
}
&:hover {
margin-top: -0.25rem;
}
}
&:not(.main) {
:deep(a) {
color: var(--color-text);
text-decoration: none;
}
}
&.right {
margin-right: 0.5rem;
}
.logout-text {
@include mobile {
display: none;
}
}
}
.spacer {
flex: 1;
}
}
}
.body {
width: 100%;
height: calc(100% - #{$header-height});
flex: 1;
overflow-y: auto;
position: relative;
}
}
</style>

View file

@ -60,6 +60,10 @@
--vt-c-lime-fg-dark: #27ae60;
--vt-c-lime-bg-light: #5cb85c;
--vt-c-lime-bg-dark: #449d44;
--vt-c-gray-fg-light: #95a5a6;
--vt-c-gray-fg-dark: #7f8c8d;
--vt-c-gray-bg-light: #d9edf7;
--vt-c-gray-bg-dark: #bce8f1;
}
/* semantic color variables for this project */
@ -74,8 +78,14 @@
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--color-accent: var(--vt-c-blue-fg-light);
--color-accent-bg: var(--vt-c-blue-bg-light);
--color-hover: var(--vt-c-blue-fg-dark);
--color-error-bg: var(--vt-c-red-bg-light);
--color-error-fg: var(--vt-c-gray-fg-light);
--default-popup-bg: var(--vt-c-green-fg-light);
--section-gap: 160px;
}
@ -91,6 +101,12 @@
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
--color-accent: var(--vt-c-blue-fg-dark);
--color-accent-bg: var(--vt-c-blue-bg-dark);
--color-error-bg: var(--vt-c-red-bg-dark);
--color-error-fg: var(--vt-c-gray-fg-dark);
--default-popup-bg: var(--vt-c-green-fg-dark);
}
}
@ -128,3 +144,4 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -70,7 +70,7 @@ import VectorLayer from 'ol/layer/Vector';
import View from 'ol/View';
import { useGeographic } from 'ol/proj';
import type { Nullable } from '../models/Types';
import type { Optional } from '../models/Types';
import Api from '../mixins/Api.vue';
import Dates from '../mixins/Dates.vue';
import FilterButton from './filter/ToggleButton.vue';
@ -108,13 +108,13 @@ export default {
data() {
return {
loading: false,
map: null as Nullable<Map>,
mapView: null as Nullable<View>,
pointsLayer: null as Nullable<VectorLayer>,
popup: null as Nullable<Overlay>,
map: null as Optional<Map>,
mapView: null as Optional<View>,
pointsLayer: null as Optional<VectorLayer>,
popup: null as Optional<Overlay>,
queryInitialized: false,
routesLayer: null as Nullable<VectorLayer>,
selectedPoint: null as Nullable<GPSPoint>,
routesLayer: null as Optional<VectorLayer>,
selectedPoint: null as Optional<GPSPoint>,
showControls: false,
showMetrics: new TimelineMetricsConfiguration(),
}
@ -361,13 +361,7 @@ export default {
@use "@/styles/common.scss" as *;
@import "ol/ol.css";
$timeline-height: 10em;
html,
body {
margin: 0;
height: 100%;
}
$timeline-height: 10rem;
main {
width: 100%;

View file

@ -0,0 +1,102 @@
<template>
<div class="messages">
<div class="message"
:class="messageClasses[message.id]"
v-for="message in messages"
:key="message.id">
<PopupMessage :icon="message.icon"
:isError="message.isError"
@click="onClick(message.id)">
{{ message.content }}
</PopupMessage>
</div>
</div>
</template>
<script lang="ts">
import Message from '../models/Message';
import PopupMessage from '../elements/PopupMessage.vue';
export default {
components: {
PopupMessage,
},
data() {
return {
messageClasses: {} as { [key: string]: string },
messages: [] as Message[],
};
},
computed: {
messageIndexById(): { [key: string]: number } {
return this.messages.reduce(
(acc: { [key: string]: number }, message: Message, index: number) => {
acc[message.id] = index;
return acc;
},
{} as { [key: string]: number }
);
},
},
methods: {
addMessage(message: any) {
if (!(message instanceof Message)) {
message = new Message(message);
}
this.messages.push(message);
if (message.timeout) {
setTimeout(
() => this.removeMessage(message.id),
message.timeout
);
}
},
removeMessage(id: string, timeout: number = 500) {
const msg = this.messages[this.messageIndexById[id]];
this.messageClasses[id] = 'remove';
setTimeout(
() => {
const index = this.messageIndexById[msg?.id]
if (index != null) {
this.messages.splice(index, 1)
}
}, timeout
);
},
onClick(id: string) {
const msg = this.messages[this.messageIndexById[id]];
msg.onClick && msg.onClick();
this.removeMessage(id, 0);
},
},
mounted() {
// @ts-ignore
this.$msgBus.on('message', this.addMessage);
},
}
</script>
<style scoped lang="scss">
.messages {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 1000;
.message {
margin-top: 0.5rem;
animation: slide-up 0.5s ease-out;
&.remove {
animation: slide-down 0.5s ease-out;
}
}
}
</style>

View file

@ -329,8 +329,8 @@ export default {
@use "@/styles/common.scss";
.filter-view {
background: var(--color-background);
height: 100%;
background: var(--color-background);
display: flex;
flex-direction: column;
align-items: center;
@ -341,6 +341,18 @@ export default {
margin-bottom: 0.25em;
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
@include common.tablet {
min-width: 45em;
}
@include common.desktop {
min-width: 45em;
}
@include common.mobile {
width: 100vw;
}
.date-selectors {
display: flex;
justify-content: space-between;

View file

@ -0,0 +1,40 @@
<template>
<div class="loading">
<div class="loading__spinner" />
</div>
</template>
<script lang="ts">
export default {
}
</script>
<style lang="scss" scoped>
.loading {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
left: 0;
&__spinner {
width: 2em;
height: 2em;
border: 5px solid #f3f3f3;
border-top: 5px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
</style>

View file

@ -0,0 +1,76 @@
<template>
<div class="popup-message" :class="{ error: isError }" @click="$emit('click')">
<div class="icon">
<font-awesome-icon :icon="iconClass" />
</div>
<div class="message">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
export default {
emits: ['click'],
props: {
icon: {
type: String,
default: 'info-circle',
},
isError: {
type: Boolean,
default: false,
},
},
computed: {
iconClass() {
const baseClass = this.isError ? 'triangle-exclamation' : this.icon;
return `fas fa-${baseClass}`;
},
},
}
</script>
<style scoped lang="scss">
.popup-message {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 0.25rem;
background-color: var(--default-popup-bg);
font-size: 1rem;
opacity: 0.9;
box-shadow: 0 0 0.25rem 0.25rem var(--color-border);
cursor: pointer;
.icon {
height: 100%;
margin-right: 1rem;
font-size: 1.5rem;
}
&.error {
background: var(--color-error-bg);
color: var(--vt-c-text-dark-1);
}
.icon {
margin-right: 1rem;
}
&:hover {
opacity: 1;
box-shadow: 0 0 0.5rem 0.25rem #888;
}
.message {
height: 100%;
display: flex;
flex: 1;
align-items: center;
}
}
</style>

View file

@ -1,9 +1,13 @@
import './assets/main.css'
import { createApp } from 'vue'
import { useStorage } from '@vueuse/core'
import App from './App.vue'
import router from './router'
import mitt from 'mitt'
/* import the fontawesome core */
import { library } from '@fortawesome/fontawesome-svg-core'
@ -17,7 +21,16 @@ import { far } from '@fortawesome/free-regular-svg-icons'
/* add icons to the library */
library.add(fas, far)
/* set up the storage */
const storage = useStorage('app-storage', {
user: null,
userSession: null,
})
const app = createApp(App)
.component('font-awesome-icon', FontAwesomeIcon)
.use(router)
.mount('#app')
app.component('font-awesome-icon', FontAwesomeIcon)
app.use(router)
app.config.globalProperties.$storage = storage
app.config.globalProperties.$msgBus = mitt()
app.mount('#app')

View file

@ -1,39 +1,13 @@
<script lang="ts">
import GPSPoint from '../models/GPSPoint';
import LocationQuery from '../models/LocationQuery';
// @ts-ignore
const baseURL = __API_PATH__
import Auth from './api/Auth.vue'
import GPSData from './api/GPSData.vue'
import Users from './api/Users.vue'
export default {
methods: {
async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> {
const response = await fetch(
`${baseURL}/gpsdata?` + new URLSearchParams(
Object.entries(query).reduce((acc: any, [key, value]) => {
if (value != null && key != 'data') {
acc[key] = value
}
if (value instanceof Date) {
acc[key] = value.getTime()
}
return acc
}, {})
)
)
return (await response.json())
.map((gps: any) => {
return new GPSPoint({
...gps,
// Normalize timestamp to Date object
timestamp: new Date(gps.timestamp),
})
})
.sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
},
},
mixins: [
Auth,
GPSData,
Users,
],
}
</script>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { type Optional } from '../../models/Types';
import Common from './Common.vue';
export default {
mixins: [Common],
methods: {
async login(payload: any): Promise<Optional<string>> {
const response = await this.request(`/auth`, {
method: 'POST',
body: payload
})
return response?.session?.token;
},
async logout(): Promise<any> {
await this.request(`/auth`, {
method: 'DELETE',
});
this.$router.push('/login');
},
},
}
</script>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import type { Optional } from '../../models/Types';
// @ts-ignore
const baseURL = __API_PATH__
export default {
methods: {
async request(path: string, params?: {
method?: string,
query?: Optional<Record<string, any>>,
body?: Optional<any>,
}): Promise<any> {
let { method, query, body } = params || {}
if (!method?.length) {
method = 'GET'
}
try {
const response = await fetch(
`${baseURL}${path}` + (
query ? '?' + new URLSearchParams(
Object.entries(query).reduce((acc: any, [key, value]) => {
if (value != null && key != 'data') {
acc[key] = value
}
if (value instanceof Date) {
acc[key] = value.getTime()
}
return acc
}, {})
) : ''
), {
method,
headers: {
'Content-Type': 'application/json',
},
...(body ? { body: JSON.stringify(body) } : {}),
}
)
let result = null
try {
result = await response.json()
} catch (error) {
result = response
}
if (!response.ok) {
throw new Error(
`${method} ${path}: ${response.status}: ${result?.error || response.statusText}`
)
}
return result
} catch (error) {
const e = (error as any)?.message || error
// @ts-ignore
this.$msgBus.emit('message', {
content: e,
isError: true,
})
}
},
},
}
</script>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import GPSPoint from '../../models/GPSPoint';
import LocationQuery from '../../models/LocationQuery';
import Common from './Common.vue';
export default {
mixins: [Common],
methods: {
async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> {
const points = await this.request('/gpsdata', {
query: query as Record<string, any>
}) || []
return points.map((gps: any) =>
new GPSPoint({
...gps,
// Normalize timestamp to Date object
timestamp: new Date(gps.timestamp),
})
)
.sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime())
},
},
}
</script>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AuthInfo from '../../models/AuthInfo';
import Common from './Common.vue';
export default {
mixins: [Common],
methods: {
async fetchUser(userId: string | number = 'me'): Promise<AuthInfo> {
return (await this.request(`/users/${userId}`)) as AuthInfo;
},
},
}
</script>

View file

@ -0,0 +1,15 @@
import type { Optional } from "./Types";
import User from "./User";
import UserSession from "./UserSession";
class AuthInfo {
public user: Optional<User>;
public userSession: Optional<UserSession>;
constructor(user?: User, userSession?: UserSession) {
this.user = user || null;
this.userSession = userSession || null;
}
}
export default AuthInfo;

View file

@ -0,0 +1,26 @@
class Message {
public id: string;
public content: string;
public icon?: string;
public isError: boolean;
public timeout?: number;
public onClick: (...args: any[]) => void = () => {};
constructor(msg: {
id?: string;
content: string;
icon?: string;
isError?: boolean;
timeout?: number;
onClick?: (...args: any[]) => void;
}) {
this.id = msg.id || Math.random().toString(36).substring(2, 11);
this.content = msg.content;
this.icon = msg.icon;
this.isError = msg.isError || false;
this.timeout = msg.timeout !== undefined ? msg.timeout : 5000;
this.onClick = msg.onClick || this.onClick;
}
}
export default Message;

View file

@ -1 +1,5 @@
export type Nullable<T> = T | null;
type Optional<T> = T | null;
export {
type Optional,
};

View file

@ -0,0 +1,31 @@
import type { Optional } from "./Types";
class User {
public id: number;
public username: number;
public email: string;
public firstName: Optional<string>;
public lastName: Optional<string>;
public createdAt: Optional<Date>;
public updatedAt: Optional<Date>;
constructor(user: {
id: number;
username: number;
email: string;
firstName?: string;
lastName?: string;
createdAt?: Date;
updatedAt?: Date;
}) {
this.id = user.id;
this.username = user.username;
this.email = user.email;
this.firstName = user.firstName || null;
this.lastName = user.lastName || null;
this.createdAt = user.createdAt || null;
this.updatedAt = user.updatedAt || null;
}
}
export default User;

View file

@ -0,0 +1,25 @@
import type { Optional } from "./Types";
class UserSession {
public id: string;
public userId: number;
public createdAt: Date;
public updatedAt: Date;
public expiresAt: Optional<Date>;
constructor(userSession: {
id: string;
userId: number;
createdAt: Date;
updatedAt: Date;
expiresAt?: Date;
}) {
this.id = userSession.id;
this.userId = userSession.userId;
this.createdAt = userSession.createdAt;
this.updatedAt = userSession.updatedAt;
this.expiresAt = userSession.expiresAt || null;
}
}
export default UserSession;

View file

@ -1,5 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import Login from '../views/Login.vue'
import Logout from '../views/Logout.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -9,6 +11,19 @@ const router = createRouter({
name: 'home',
component: HomeView,
},
{
path: '/login',
name: 'login',
component: Login,
},
{
path: '/logout',
name: 'logout',
component: Logout,
},
//{
// path: '/about',
// name: 'about',

View file

@ -35,6 +35,7 @@ button {
cursor: pointer;
&:disabled {
color: var(--color-text);
opacity: 0.5;
cursor: not-allowed;
}
@ -44,8 +45,70 @@ button {
}
}
button[type=submit] {
[type=submit] {
background: var(--color-accent);
color: white !important;
margin: 0.5em;
font-size: 1.15em;
padding: 0.5em;
transition: background-color 0.5s;
&:hover {
background: var(--color-hover);
color: var(--color-background);
}
}
[type=text], [type=email], [type=password], textarea {
width: 100%;
background: var(--color-background-soft);
color: var(--color-text);
padding: 0.5em;
border: 1px solid var(--color-border);
border-radius: 4px;
transition: border-color 0.5s;
transition: border-color 0.5s;
//
width: 100%;
padding: 0.5em;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
}
/* Animations */
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes slide-down {
from {
transform: translateY(0);
}
to {
transform: translateY(100%);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View file

@ -0,0 +1,123 @@
<template>
<main>
<Loading v-if="loading" />
<form @submit.prevent="onSubmit">
<h1 class="title">
<font-awesome-icon icon="map-marker-alt" />
GPSTracker
</h1>
<div class="row">
<input type="text"
placeholder="Username"
v-model="username"
:disabled="loading"
ref="username" />
</div>
<div class="row">
<input type="password"
placeholder="Password"
:disabled="loading"
v-model="password" />
</div>
<div class="row">
<button type="submit" :disabled="loading">
<font-awesome-icon icon="sign-in-alt" />
Login
</button>
</div>
</form>
</main>
</template>
<script lang="ts">
import Api from '../mixins/Api.vue';
import Loading from '../elements/Loading.vue';
export default {
mixins: [Api],
components: {
Loading,
},
data() {
return {
loading: false,
username: '',
password: '',
};
},
methods: {
async onSubmit() {
if (this.loading) {
return;
}
this.loading = true;
const redirect = this.$route.query.redirect || '/';
try {
const sessionToken = await this.login({
username: this.username,
password: this.password,
});
if (sessionToken?.length) {
window.location.href = redirect as string;
}
} catch (error) {
// @ts-ignore
this.$msgBus.emit('message', {
content: (error as any)?.message || error,
isError: true,
})
} finally {
this.loading = false;
}
},
},
mounted() {
this.$nextTick(() => {
(this.$refs.username as HTMLElement).focus();
});
},
};
</script>
<style lang="scss" scoped>
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
background: var(--vt-c-green-bg-light);
}
form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 25em;
max-width: 80%;
background: var(--color-background);
border: 1px solid var(--color-border);
box-shadow: 2px 2px 2px 2px var(--color-border);
border-radius: 0.5em;
padding: 1em;
.row {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
padding: 0.5em;
}
}
</style>

View file

@ -0,0 +1,19 @@
<template>
<Loading />
</template>
<script lang="ts">
import Api from '../mixins/Api.vue';
import Loading from '../elements/Loading.vue';
export default {
mixins: [Api],
components: {
Loading,
},
async mounted() {
await this.logout();
},
};
</script>

View file

@ -23,7 +23,7 @@ function authenticate(roles: RoleName[] = []) {
let session: Optional<UserSession>;
// Check the `session` cookie or the `Authorization` header for the session token
let token = req.cookies?.session;
let token = req.headers?.cookie?.match(/session=([^;]+)/)?.[1];
if (!token?.length) {
const authHeader = req.headers?.authorization;
if (authHeader?.startsWith('Bearer ')) {

View file

@ -37,17 +37,23 @@ abstract class Route {
}
if (error instanceof Forbidden) {
res.status(403).send(error.message);
res.status(403).json({
error: error.message,
});
return;
}
if (error instanceof NotFound) {
res.status(404).send(error.message);
res.status(404).json({
error: error.message,
});
return;
}
if (error instanceof BadRequest) {
res.status(400).send(error.message);
res.status(400).json({
error: error.message,
});
return;
}

View file

@ -13,7 +13,10 @@ abstract class ApiRoute extends Route {
protected static handleError(req: Request, res: Response, error: Error) {
// Handle API unauthorized errors with a 401+JSON response instead of a redirect
if (error instanceof Unauthorized) {
res.status(401).send(error.message);
res.status(401).json({
error: error.message,
});
return;
}

View file

@ -62,7 +62,7 @@ class Auth extends ApiV1Route {
* If the user is authenticated, the session will be destroyed and the cookie will be cleared.
*/
@authenticate()
delete = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
delete = async (_: Request, res: Response, auth?: Optional<AuthInfo>) => {
const session = auth!.session;
if (session) {