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>
|
</RouterLink>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
|
<DropdownItem>
|
||||||
|
<RouterLink to="/api">
|
||||||
|
<font-awesome-icon icon="code" />
|
||||||
|
<span class="item-text">API</span>
|
||||||
|
</RouterLink>
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem>
|
<DropdownItem>
|
||||||
<RouterLink to="/logout">
|
<RouterLink to="/logout">
|
||||||
<font-awesome-icon icon="sign-out-alt" />
|
<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>
|
<template>
|
||||||
<div class="devices-list">
|
<div class="list devices-list">
|
||||||
<h1>
|
<h1>
|
||||||
<font-awesome-icon icon="mobile-alt" />
|
<font-awesome-icon icon="mobile-alt" />
|
||||||
My devices
|
My devices
|
||||||
|
@ -36,31 +36,4 @@ export default {
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use '@/styles/common.scss' as *;
|
@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>
|
</style>
|
||||||
|
|
|
@ -5,14 +5,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<form class="confirm-dialog" @submit.prevent="$emit('confirm')">
|
<form class="confirm-dialog" @submit.prevent="$emit('confirm')">
|
||||||
<div class="wrapper">
|
<div class="content">
|
||||||
<div class="content">
|
<slot />
|
||||||
<slot />
|
</div>
|
||||||
</div>
|
<div class="buttons">
|
||||||
<div class="buttons">
|
<button type="submit" @click="$emit('confirm')" :disabled="disabled">Confirm</button>
|
||||||
<button type="submit" @click="$emit('confirm')" :disabled="disabled">Confirm</button>
|
<button type="button" @click="$emit('close')" :disabled="disabled">Cancel</button>
|
||||||
<button type="button" @click="$emit('close')" :disabled="disabled">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import Auth from './api/Auth.vue'
|
import Auth from './api/Auth.vue'
|
||||||
import Devices from './api/Devices.vue'
|
import Devices from './api/Devices.vue'
|
||||||
import GPSData from './api/GPSData.vue'
|
import GPSData from './api/GPSData.vue'
|
||||||
|
import Sessions from './api/Sessions.vue'
|
||||||
import Users from './api/Users.vue'
|
import Users from './api/Users.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -9,6 +10,7 @@ export default {
|
||||||
Auth,
|
Auth,
|
||||||
Devices,
|
Devices,
|
||||||
GPSData,
|
GPSData,
|
||||||
|
Sessions,
|
||||||
Users,
|
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 {
|
class UserSession {
|
||||||
public id: string;
|
public id: string;
|
||||||
public userId: number;
|
public userId: number;
|
||||||
|
public name: string;
|
||||||
|
public isApi: boolean;
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
public expiresAt: Optional<Date>;
|
public expiresAt: Optional<Date>;
|
||||||
|
|
||||||
constructor(userSession: {
|
constructor({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
isApi = false,
|
||||||
|
createdAt,
|
||||||
|
expiresAt = null,
|
||||||
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
isApi?: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
expiresAt?: Date;
|
expiresAt?: Optional<Date>;
|
||||||
}) {
|
}) {
|
||||||
this.id = userSession.id;
|
this.id = id;
|
||||||
this.userId = userSession.userId;
|
this.userId = userId;
|
||||||
this.createdAt = userSession.createdAt;
|
this.name = name;
|
||||||
this.expiresAt = userSession.expiresAt || null;
|
this.isApi = isApi;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.expiresAt = expiresAt || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import API from '../views/API.vue'
|
||||||
import Devices from '../views/Devices.vue'
|
import Devices from '../views/Devices.vue'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import Login from '../views/Login.vue'
|
import Login from '../views/Login.vue'
|
||||||
|
@ -19,6 +20,12 @@ const router = createRouter({
|
||||||
component: Devices,
|
component: Devices,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/api',
|
||||||
|
name: 'api',
|
||||||
|
component: API,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
|
|
|
@ -2,3 +2,4 @@
|
||||||
@forward "./elements.scss";
|
@forward "./elements.scss";
|
||||||
@forward "./layout.scss";
|
@forward "./layout.scss";
|
||||||
@forward "./variables.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>
|
<template>
|
||||||
<div class="devices">
|
<div class="devices view">
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<div class="loading-container" v-if="loading">
|
<div class="loading-container" v-if="loading">
|
||||||
<Loading />
|
<Loading />
|
||||||
|
@ -99,30 +99,4 @@ export default {
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use '@/styles/common.scss' as *;
|
@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>
|
</style>
|
||||||
|
|
|
@ -186,6 +186,11 @@ async function createUserSessionsTable(query: { context: any }) {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
|
isApi: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
expiresAt: {
|
expiresAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|
|
@ -13,6 +13,15 @@ function UserSession(): Record<string, any> {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
isApi: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
expiresAt: {
|
expiresAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|
|
@ -6,6 +6,7 @@ class UserSession {
|
||||||
public id: string;
|
public id: string;
|
||||||
public userId: number;
|
public userId: number;
|
||||||
public name?: Optional<string>;
|
public name?: Optional<string>;
|
||||||
|
public isApi: boolean;
|
||||||
public expiresAt: Optional<Date>;
|
public expiresAt: Optional<Date>;
|
||||||
public createdAt: Optional<Date>;
|
public createdAt: Optional<Date>;
|
||||||
|
|
||||||
|
@ -13,12 +14,21 @@ class UserSession {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
|
isApi = false,
|
||||||
expiresAt = null,
|
expiresAt = null,
|
||||||
createdAt = null,
|
createdAt = null,
|
||||||
}: any) {
|
}: {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
name?: Optional<string>;
|
||||||
|
isApi?: boolean;
|
||||||
|
expiresAt?: Optional<Date>;
|
||||||
|
createdAt?: Optional<Date>;
|
||||||
|
}) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
this.isApi = isApi;
|
||||||
this.expiresAt = expiresAt;
|
this.expiresAt = expiresAt;
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
|
|
||||||
|
|
|
@ -23,10 +23,12 @@ class UserSessions {
|
||||||
public async create(userId: number, args: {
|
public async create(userId: number, args: {
|
||||||
expiresAt?: Optional<Date>,
|
expiresAt?: Optional<Date>,
|
||||||
name?: Optional<string>,
|
name?: Optional<string>,
|
||||||
|
isApi?: Optional<boolean>,
|
||||||
}): Promise<UserSession> {
|
}): Promise<UserSession> {
|
||||||
const session = await $db.UserSession().create({
|
const session = await $db.UserSession().create({
|
||||||
userId,
|
userId,
|
||||||
name: args.name,
|
name: args.name,
|
||||||
|
isApi: args.isApi || false,
|
||||||
expiresAt: args.expiresAt ? new Date(args.expiresAt).toISOString() : null,
|
expiresAt: args.expiresAt ? new Date(args.expiresAt).toISOString() : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -57,6 +59,19 @@ class UserSessions {
|
||||||
|
|
||||||
return session;
|
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;
|
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 DevicesById from "./DevicesById";
|
||||||
import GPSData from "./GPSData";
|
import GPSData from "./GPSData";
|
||||||
import Routes from "../../Routes";
|
import Routes from "../../Routes";
|
||||||
|
import Tokens from "./Tokens";
|
||||||
|
import TokensById from "./TokensById";
|
||||||
import UserSelf from "./UserSelf";
|
import UserSelf from "./UserSelf";
|
||||||
|
|
||||||
class ApiV1Routes extends Routes {
|
class ApiV1Routes extends Routes {
|
||||||
|
@ -11,6 +13,8 @@ class ApiV1Routes extends Routes {
|
||||||
new Devices(),
|
new Devices(),
|
||||||
new DevicesById(),
|
new DevicesById(),
|
||||||
new GPSData(),
|
new GPSData(),
|
||||||
|
new Tokens(),
|
||||||
|
new TokensById(),
|
||||||
new UserSelf(),
|
new UserSelf(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue