Added support for API tokens.

This commit is contained in:
Fabio Manganiello 2025-03-09 14:02:00 +01:00
parent 5f39251acc
commit 95925b0289
Signed by: blacklight
GPG key ID: D90FBA7F76362774
21 changed files with 804 additions and 70 deletions

View file

@ -25,6 +25,13 @@
</RouterLink>
</DropdownItem>
<DropdownItem>
<RouterLink to="/api">
<font-awesome-icon icon="code" />&nbsp;&nbsp;
<span class="item-text">API</span>
</RouterLink>
</DropdownItem>
<DropdownItem>
<RouterLink to="/logout">
<font-awesome-icon icon="sign-out-alt" />&nbsp;&nbsp;

View file

@ -0,0 +1,150 @@
<template>
<div class="token item" @click="showDetails = !showDetails">
<div class="loading-container" v-if="loading">
<Loading />
</div>
<div class="header" :class="{ expanded: showDetails }">
<h2>
{{ token.name?.length ? token.name : `Created on ${formatDate(token.createdAt)}` }}
</h2>
<div class="buttons wide" @click.stop>
<button type="button" title="Delete" @click="showDeleteDialog = true">
<font-awesome-icon icon="trash" />&nbsp;
</button>
<button type="button" title="Details" @click="showDetails = !showDetails">
<font-awesome-icon icon="info-circle" />&nbsp;
</button>
</div>
<div class="buttons mobile" @click.stop>
<Dropdown>
<template #button>
<button class="options" title="Options">
<font-awesome-icon icon="ellipsis-h" />
</button>
</template>
<DropdownItem @click="showDeleteDialog = true">
<template #icon>
<font-awesome-icon icon="trash" />
</template>
Delete
</DropdownItem>
<DropdownItem @click="showDetails = !showDetails">
<template #icon>
<font-awesome-icon icon="info-circle" />
</template>
Details
</DropdownItem>
</Dropdown>
</div>
</div>
<div class="details" v-if="showDetails" @click.stop>
<div class="row">
<div class="property">
Token ID
</div>
<div class="value">
{{ token.id }}
</div>
</div>
<div class="row creation-date">
<div class="property">
Created at
</div>
<div class="value">
{{ formatDate(token.createdAt) }}
</div>
</div>
<div class="row creation-date">
<div class="property">
Expires at
</div>
<div class="value">
{{ formatDate(token.expiresAt) }}
</div>
</div>
</div>
</div>
<ConfirmDialog
:visible="showDeleteDialog"
:disabled="loading"
v-if="showDeleteDialog"
@close="showDeleteDialog = false"
@confirm="runDelete">
<template #title>
Delete token
</template>
<p>
Are you sure you want to delete this token?<br/>
Any application using this token will stop working.
</p>
</ConfirmDialog>
</template>
<script lang="ts">
import Api from '../../mixins/Api.vue';
import ConfirmDialog from '../../elements/ConfirmDialog.vue';
import Dates from '../../mixins/Dates.vue';
import Dropdown from '../../elements/Dropdown.vue';
import DropdownItem from '../../elements/DropdownItem.vue';
import Loading from '../../elements/Loading.vue';
import UserSession from '../../models/UserSession';
export default {
emits: ['delete'],
mixins: [
Api,
Dates,
],
components: {
ConfirmDialog,
Dropdown,
DropdownItem,
Loading,
},
props: {
token: {
type: Object as () => UserSession,
required: true,
},
},
data() {
return {
loading: false,
showDeleteDialog: false,
showDetails: false,
}
},
methods: {
async runDelete() {
this.showDeleteDialog = false;
this.loading = true;
try {
await this.deleteToken(this.token.id);
this.$emit('delete', this.token);
} finally {
this.loading = false;
}
},
},
}
</script>
<style lang="scss" scoped>
@use "@/styles/common.scss" as *;
</style>

View file

@ -0,0 +1,142 @@
<template>
<form @submit.prevent="create" @input.stop.prevent>
<div class="loading-container" v-if="loading">
<Loading />
</div>
<div class="row" v-if="!token">
<label for="name">
(Optional) Enter a name to identify your token.
</label>
<input type="text" ref="name" placeholder="Token name" />
</div>
<div class="row" v-if="!token">
<label for="expiresAt">
(Optional) Expires at
</label>
<input type="datetime-local" ref="expiresAt" />
</div>
<div class="row" v-if="token">
<label for="token">
Your token<br />
<small>Copy it now, you won't be able to see it again.</small>
</label>
<textarea type="text"
readonly
@click="tokenElement.select()"
ref="token"
v-model="token" />
</div>
<div class="row buttons" v-else>
<button type="submit">
Create token
</button>
<button type="button" @click="$emit('close')">
Cancel
</button>
</div>
</form>
</template>
<script lang="ts">
import { type Optional } from '../../models/Types';
import Api from '../../mixins/Api.vue';
import Loading from '../../elements/Loading.vue';
import Notifications from '../../mixins/Notifications.vue';
export default {
emits: ['close', 'input'],
mixins: [
Api,
Notifications,
],
components: {
Loading,
},
data() {
return {
expiresAt: null as Optional<string>,
loading: false,
token: null as Optional<string>,
}
},
computed: {
nameElement(): HTMLInputElement {
return this.$refs.name as HTMLInputElement;
},
expiresAtElement(): HTMLInputElement {
return this.$refs.expiresAt as HTMLInputElement;
},
tokenElement(): HTMLTextAreaElement {
return this.$refs.token as HTMLTextAreaElement;
},
},
methods: {
async create() {
let name = this.nameElement.value.trim() as Optional<string>;
if (!name?.length) {
name = null;
}
let expiresAt = this.expiresAtElement.value.trim() as Optional<string>;
if (!expiresAt?.length) {
expiresAt = null;
}
this.loading = true;
try {
this.token = await this.createToken(name, expiresAt ? new Date(expiresAt) : null);
this.$emit('input', this.token);
} finally {
this.loading = false;
}
},
},
async mounted() {
this.nameElement.focus();
},
}
</script>
<style lang="scss" scoped>
form {
width: 100%;
max-width: 300px;
margin: 0 auto;
position: relative;
.row {
margin-bottom: 0.5em;
}
.buttons {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 1em;
button {
height: 2.3em;
}
}
textarea {
width: 100%;
height: 10em;
}
}
</style>

View file

@ -0,0 +1,37 @@
<template>
<div class="list tokens-list">
<h1>
<font-awesome-icon icon="code" />
API Tokens
</h1>
<ul>
<li v-for="token in tokens" :key="token.id">
<Token :token="token" @delete="$emit('delete', token)" />
</li>
</ul>
</div>
</template>
<script lang="ts">
import Token from './Token.vue';
import UserSession from '../../models/UserSession';
export default {
emits: ['delete', 'update'],
components: {
Token,
},
props: {
tokens: {
type: Array as () => UserSession[],
required: true,
},
},
}
</script>
<style lang="scss" scoped>
@use '@/styles/common.scss' as *;
</style>

View file

@ -1,5 +1,5 @@
<template>
<div class="devices-list">
<div class="list devices-list">
<h1>
<font-awesome-icon icon="mobile-alt" />&nbsp;
My devices
@ -36,31 +36,4 @@ export default {
<style lang="scss" scoped>
@use '@/styles/common.scss' as *;
.devices-list {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
@include media(tablet) {
min-width: 30em;
}
h1 {
width: 100%;
text-align: center;
padding: 0.5em 0;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
width: 100%;
overflow-y: auto;
}
}
</style>

View file

@ -5,14 +5,12 @@
</template>
<form class="confirm-dialog" @submit.prevent="$emit('confirm')">
<div class="wrapper">
<div class="content">
<slot />
</div>
<div class="buttons">
<button type="submit" @click="$emit('confirm')" :disabled="disabled">Confirm</button>
<button type="button" @click="$emit('close')" :disabled="disabled">Cancel</button>
</div>
<div class="content">
<slot />
</div>
<div class="buttons">
<button type="submit" @click="$emit('confirm')" :disabled="disabled">Confirm</button>
<button type="button" @click="$emit('close')" :disabled="disabled">Cancel</button>
</div>
</form>
</Modal>

View file

@ -2,6 +2,7 @@
import Auth from './api/Auth.vue'
import Devices from './api/Devices.vue'
import GPSData from './api/GPSData.vue'
import Sessions from './api/Sessions.vue'
import Users from './api/Users.vue'
export default {
@ -9,6 +10,7 @@ export default {
Auth,
Devices,
GPSData,
Sessions,
Users,
],
}

View file

@ -0,0 +1,37 @@
<script lang="ts">
import type { Optional } from '../../models/Types';
import UserSession from '../../models/UserSession';
import Common from './Common.vue';
export default {
mixins: [Common],
methods: {
async getMyTokens(): Promise<UserSession[]> {
return (
await this.request(`/tokens`) as {
tokens: UserSession[]
}
).tokens;
},
async createToken(
name: Optional<string> = null,
expiresAt: Optional<Date> = null,
): Promise<string> {
return (
await this.request(`/tokens`, {
method: 'POST',
body: {
name,
expiresAt,
},
}) as { token: string }
).token;
},
async deleteToken(id: string): Promise<void> {
await this.request(`/tokens/${id}`, { method: 'DELETE' });
},
},
}
</script>

View file

@ -3,19 +3,32 @@ import type { Optional } from "./Types";
class UserSession {
public id: string;
public userId: number;
public name: string;
public isApi: boolean;
public createdAt: Date;
public expiresAt: Optional<Date>;
constructor(userSession: {
constructor({
id,
userId,
name,
isApi = false,
createdAt,
expiresAt = null,
}: {
id: string;
userId: number;
name: string;
isApi?: boolean;
createdAt: Date;
expiresAt?: Date;
expiresAt?: Optional<Date>;
}) {
this.id = userSession.id;
this.userId = userSession.userId;
this.createdAt = userSession.createdAt;
this.expiresAt = userSession.expiresAt || null;
this.id = id;
this.userId = userId;
this.name = name;
this.isApi = isApi;
this.createdAt = createdAt;
this.expiresAt = expiresAt || null;
}
}

View file

@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import API from '../views/API.vue'
import Devices from '../views/Devices.vue'
import HomeView from '../views/HomeView.vue'
import Login from '../views/Login.vue'
@ -19,6 +20,12 @@ const router = createRouter({
component: Devices,
},
{
path: '/api',
name: 'api',
component: API,
},
{
path: '/login',
name: 'login',

View file

@ -2,3 +2,4 @@
@forward "./elements.scss";
@forward "./layout.scss";
@forward "./variables.scss";
@forward "./views.scss";

View file

@ -0,0 +1,169 @@
@use './layout.scss' as *;
.view {
height: 100%;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
justify-content: center;
padding: 2em;
.wrapper {
height: 100%;
display: flex;
background-color: var(--color-background);
border-radius: 1em;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.2);
position: relative;
@include media(mobile) {
width: 100%;
}
@include media(tablet) {
min-width: 30em;
max-width: 40em;
}
}
.list {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
@include media(tablet) {
min-width: 30em;
}
h1 {
width: 100%;
text-align: center;
padding: 0.5em 0;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
width: 100%;
overflow-y: auto;
}
.item {
width: 100%;
margin: 0;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
position: relative;
font-size: 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
cursor: pointer;
@include media(tablet) {
min-width: 30em;
}
.header {
display: flex;
justify-content: space-between;
width: 100%;
padding: 0.5em 0;
&:hover {
h2 {
color: var(--color-hover);
}
}
&.expanded {
background-color: rgba(0, 0, 0, 0.05);
font-weight: bold;
padding: 0.5em;
border-radius: 0.75rem;
}
}
h2 {
font-size: 1.25em;
padding: 0.5em 0;
flex: 1;
}
.buttons {
display: flex;
align-items: center;
justify-content: flex-end;
display: none;
&.mobile {
display: flex;
}
&.wide {
display: none;
}
@include media(tablet) {
&.mobile {
display: none !important;
}
&.wide {
display: flex !important;
}
}
:deep(button) {
background-color: transparent;
color: var(--color-text);
border: none;
cursor: pointer;
font-size: 1em;
opacity: 0.75;
&:hover {
opacity: 1;
}
}
}
.details {
cursor: initial;
animation: fade-in 0.5s;
padding: 0.5em 0;
.row {
display: flex;
flex-direction: column;
@include media(tablet) {
flex-direction: row;
justify-content: space-between;
}
&:hover {
.property {
color: var(--color-hover);
}
}
.property {
font-weight: bold;
margin-bottom: 0.25em;
@include media(tablet) {
margin-bottom: 0;
}
}
.value {
font-size: 0.9em;
}
}
}
}
}
}

View file

@ -0,0 +1,92 @@
<template>
<div class="api view">
<div class="wrapper">
<div class="loading-container" v-if="loading">
<Loading />
</div>
<TokensList :tokens="tokens" @delete="onDelete" />
</div>
<Modal :visible="showTokenForm" @close="clearForm">
<template v-slot:title>
New API token
</template>
<TokenForm @close="clearForm" @input="refresh" />
</Modal>
<FloatingButton
icon="fas fa-plus"
title="Create a new API token"
@click="showTokenForm = true" />
</div>
</template>
<script lang="ts">
import Api from '../mixins/Api.vue';
import FloatingButton from '../elements/FloatingButton.vue';
import Loading from '../elements/Loading.vue';
import Modal from '../elements/Modal.vue';
import TokenForm from '../components/api/TokenForm.vue';
import TokensList from '../components/api/TokensList.vue';
import UserSession from '../models/UserSession';
export default {
mixins: [Api],
components: {
FloatingButton,
Loading,
Modal,
TokenForm,
TokensList,
},
data() {
return {
loading: false,
showTokenForm: false,
tokens: [] as UserSession[],
}
},
computed: {
tokenIndexById() {
return this.tokens.reduce(
(acc: Record<string, number>, token: UserSession, index: number) => {
acc[token.id] = index;
return acc;
}, {} as Record<string, number>
);
},
},
methods: {
clearForm() {
this.showTokenForm = false;
},
onDelete(token: UserSession) {
const index = this.tokenIndexById[token.id];
this.tokens.splice(index, 1);
},
async refresh() {
this.loading = true;
try {
this.tokens = await this.getMyTokens()
} finally {
this.loading = false;
}
},
},
async mounted() {
await this.refresh();
},
}
</script>
<style lang="scss" scoped>
@use '@/styles/common.scss' as *;
</style>

View file

@ -1,5 +1,5 @@
<template>
<div class="devices">
<div class="devices view">
<div class="wrapper">
<div class="loading-container" v-if="loading">
<Loading />
@ -99,30 +99,4 @@ export default {
<style lang="scss" scoped>
@use '@/styles/common.scss' as *;
.devices {
height: 100%;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
justify-content: center;
padding: 2em;
.wrapper {
height: 100%;
display: flex;
background-color: var(--color-background);
border-radius: 1em;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.2);
position: relative;
@include media(mobile) {
width: 100%;
}
@include media(tablet) {
min-width: 30em;
max-width: 40em;
}
}
}
</style>

View file

@ -186,6 +186,11 @@ async function createUserSessionsTable(query: { context: any }) {
type: DataTypes.STRING,
allowNull: true,
},
isApi: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
expiresAt: {
type: DataTypes.DATE,
allowNull: true,

View file

@ -13,6 +13,15 @@ function UserSession(): Record<string, any> {
type: DataTypes.INTEGER,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: true,
},
isApi: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
expiresAt: {
type: DataTypes.DATE,
allowNull: true,

View file

@ -6,6 +6,7 @@ class UserSession {
public id: string;
public userId: number;
public name?: Optional<string>;
public isApi: boolean;
public expiresAt: Optional<Date>;
public createdAt: Optional<Date>;
@ -13,12 +14,21 @@ class UserSession {
id,
userId,
name,
isApi = false,
expiresAt = null,
createdAt = null,
}: any) {
}: {
id: string;
userId: number;
name?: Optional<string>;
isApi?: boolean;
expiresAt?: Optional<Date>;
createdAt?: Optional<Date>;
}) {
this.id = id;
this.userId = userId;
this.name = name;
this.isApi = isApi;
this.expiresAt = expiresAt;
this.createdAt = createdAt;

View file

@ -23,10 +23,12 @@ class UserSessions {
public async create(userId: number, args: {
expiresAt?: Optional<Date>,
name?: Optional<string>,
isApi?: Optional<boolean>,
}): Promise<UserSession> {
const session = await $db.UserSession().create({
userId,
name: args.name,
isApi: args.isApi || false,
expiresAt: args.expiresAt ? new Date(args.expiresAt).toISOString() : null,
});
@ -57,6 +59,19 @@ class UserSessions {
return session;
}
public async byUser(userId: number, { isApi }: { isApi?: boolean } = {}): Promise<UserSession[]> {
const filter = { userId } as { userId: number, isApi?: boolean };
if (isApi != null) {
filter['isApi'] = !!isApi;
}
return (
await $db.UserSession().findAll({
where: filter,
})
).map((session: any) => new UserSession(session.dataValues));
}
}
export default UserSessions;

View file

@ -0,0 +1,52 @@
import { Optional } from '../../../types';
import { Request, Response } from 'express';
import ApiV1Route from './Route';
import { ValidationError } from '../../../errors';
import { AuthInfo, authenticate } from '../../../auth';
class Tokens extends ApiV1Route {
constructor() {
super('/tokens');
}
/**
* Create a new API token for the user.
*/
@authenticate()
post = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
const user = auth!.user;
const expiresAt = req.body?.expiresAt;
let expiresAtDate: Optional<Date> = null;
if (expiresAt) {
try {
expiresAtDate = new Date(expiresAt);
} catch (error) {
throw new ValidationError(`Invalid expiresAt: ${error}`);
}
}
const session = await $repos.userSessions.create(user.id, {
name: req.body?.name,
isApi: true,
expiresAt: expiresAtDate,
})
res.json({
token: session.getToken(),
});
}
/**
* List all the API tokens for the user.
*/
@authenticate()
get = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
const user = auth!.user;
const sessions = await $repos.userSessions.byUser(user.id, { isApi: true });
res.json({ tokens: sessions });
}
}
export default Tokens;

View file

@ -0,0 +1,37 @@
import { Optional } from '../../../types';
import { Request, Response } from 'express';
import ApiV1Route from './Route';
import { AuthInfo, authenticate } from '../../../auth';
import { RoleName } from '../../../models';
class TokensById extends ApiV1Route {
constructor() {
super('/tokens/:id');
}
/**
* Delete an API token given its (session) ID.
*/
@authenticate()
delete = async (req: Request, res: Response, auth?: Optional<AuthInfo>) => {
const user = auth!.user;
const sessionId = req.params.id;
const session = await $repos.userSessions.find(sessionId);
if (!session) {
res.status(404).send();
return;
}
if (session.userId !== user.id) {
// Only the owner of the token or admin users can delete it.
authenticate([RoleName.Admin]);
}
await session.destroy();
res.status(204).send();
}
}
export default TokensById;

View file

@ -3,6 +3,8 @@ import Devices from "./Devices";
import DevicesById from "./DevicesById";
import GPSData from "./GPSData";
import Routes from "../../Routes";
import Tokens from "./Tokens";
import TokensById from "./TokensById";
import UserSelf from "./UserSelf";
class ApiV1Routes extends Routes {
@ -11,6 +13,8 @@ class ApiV1Routes extends Routes {
new Devices(),
new DevicesById(),
new GPSData(),
new Tokens(),
new TokensById(),
new UserSelf(),
];
}