Added support for API tokens.
This commit is contained in:
parent
5f39251acc
commit
95925b0289
21 changed files with 804 additions and 70 deletions
frontend/src
components
elements
mixins
models
router
styles
views
src
db
models
repos
routes/api/v1
|
@ -25,6 +25,13 @@
|
|||
</RouterLink>
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem>
|
||||
<RouterLink to="/api">
|
||||
<font-awesome-icon icon="code" />
|
||||
<span class="item-text">API</span>
|
||||
</RouterLink>
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem>
|
||||
<RouterLink to="/logout">
|
||||
<font-awesome-icon icon="sign-out-alt" />
|
||||
|
|
150
frontend/src/components/api/Token.vue
Normal file
150
frontend/src/components/api/Token.vue
Normal 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" />
|
||||
</button>
|
||||
|
||||
<button type="button" title="Details" @click="showDetails = !showDetails">
|
||||
<font-awesome-icon icon="info-circle" />
|
||||
</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>
|
142
frontend/src/components/api/TokenForm.vue
Normal file
142
frontend/src/components/api/TokenForm.vue
Normal 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>
|
37
frontend/src/components/api/TokensList.vue
Normal file
37
frontend/src/components/api/TokensList.vue
Normal 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>
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="devices-list">
|
||||
<div class="list devices-list">
|
||||
<h1>
|
||||
<font-awesome-icon icon="mobile-alt" />
|
||||
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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
}
|
||||
|
|
37
frontend/src/mixins/api/Sessions.vue
Normal file
37
frontend/src/mixins/api/Sessions.vue
Normal 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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -2,3 +2,4 @@
|
|||
@forward "./elements.scss";
|
||||
@forward "./layout.scss";
|
||||
@forward "./variables.scss";
|
||||
@forward "./views.scss";
|
||||
|
|
169
frontend/src/styles/views.scss
Normal file
169
frontend/src/styles/views.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
92
frontend/src/views/API.vue
Normal file
92
frontend/src/views/API.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
52
src/routes/api/v1/Tokens.ts
Normal file
52
src/routes/api/v1/Tokens.ts
Normal 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;
|
37
src/routes/api/v1/TokensById.ts
Normal file
37
src/routes/api/v1/TokensById.ts
Normal 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;
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue