diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 0937729..154638b 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -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" /> diff --git a/frontend/src/components/api/Token.vue b/frontend/src/components/api/Token.vue new file mode 100644 index 0000000..23277af --- /dev/null +++ b/frontend/src/components/api/Token.vue @@ -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> diff --git a/frontend/src/components/api/TokenForm.vue b/frontend/src/components/api/TokenForm.vue new file mode 100644 index 0000000..71b3e96 --- /dev/null +++ b/frontend/src/components/api/TokenForm.vue @@ -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> diff --git a/frontend/src/components/api/TokensList.vue b/frontend/src/components/api/TokensList.vue new file mode 100644 index 0000000..ff3502d --- /dev/null +++ b/frontend/src/components/api/TokensList.vue @@ -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> diff --git a/frontend/src/components/devices/DevicesList.vue b/frontend/src/components/devices/DevicesList.vue index 89a646e..5c0b8b2 100644 --- a/frontend/src/components/devices/DevicesList.vue +++ b/frontend/src/components/devices/DevicesList.vue @@ -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> diff --git a/frontend/src/elements/ConfirmDialog.vue b/frontend/src/elements/ConfirmDialog.vue index dbb88f9..f639c85 100644 --- a/frontend/src/elements/ConfirmDialog.vue +++ b/frontend/src/elements/ConfirmDialog.vue @@ -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> diff --git a/frontend/src/mixins/Api.vue b/frontend/src/mixins/Api.vue index 84a0654..e10871b 100644 --- a/frontend/src/mixins/Api.vue +++ b/frontend/src/mixins/Api.vue @@ -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, ], } diff --git a/frontend/src/mixins/api/Sessions.vue b/frontend/src/mixins/api/Sessions.vue new file mode 100644 index 0000000..b284c74 --- /dev/null +++ b/frontend/src/mixins/api/Sessions.vue @@ -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> diff --git a/frontend/src/models/UserSession.ts b/frontend/src/models/UserSession.ts index 7079a77..7701d04 100644 --- a/frontend/src/models/UserSession.ts +++ b/frontend/src/models/UserSession.ts @@ -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; } } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bae97bd..15f1b82 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/styles/common.scss b/frontend/src/styles/common.scss index da41ae1..7c47449 100644 --- a/frontend/src/styles/common.scss +++ b/frontend/src/styles/common.scss @@ -2,3 +2,4 @@ @forward "./elements.scss"; @forward "./layout.scss"; @forward "./variables.scss"; +@forward "./views.scss"; diff --git a/frontend/src/styles/views.scss b/frontend/src/styles/views.scss new file mode 100644 index 0000000..3f7f7a0 --- /dev/null +++ b/frontend/src/styles/views.scss @@ -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; + } + } + } + } + } +} diff --git a/frontend/src/views/API.vue b/frontend/src/views/API.vue new file mode 100644 index 0000000..ac3cf7a --- /dev/null +++ b/frontend/src/views/API.vue @@ -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> diff --git a/frontend/src/views/Devices.vue b/frontend/src/views/Devices.vue index 07dd48b..29271e2 100644 --- a/frontend/src/views/Devices.vue +++ b/frontend/src/views/Devices.vue @@ -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> diff --git a/src/db/migrations/000_initial.ts b/src/db/migrations/000_initial.ts index baf2688..407a074 100644 --- a/src/db/migrations/000_initial.ts +++ b/src/db/migrations/000_initial.ts @@ -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, diff --git a/src/db/types/UserSession.ts b/src/db/types/UserSession.ts index b972043..100841a 100644 --- a/src/db/types/UserSession.ts +++ b/src/db/types/UserSession.ts @@ -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, diff --git a/src/models/UserSession.ts b/src/models/UserSession.ts index 5672108..ee7d78f 100644 --- a/src/models/UserSession.ts +++ b/src/models/UserSession.ts @@ -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; diff --git a/src/repos/UserSessions.ts b/src/repos/UserSessions.ts index 9e08a55..3db95a8 100644 --- a/src/repos/UserSessions.ts +++ b/src/repos/UserSessions.ts @@ -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; diff --git a/src/routes/api/v1/Tokens.ts b/src/routes/api/v1/Tokens.ts new file mode 100644 index 0000000..8d314b3 --- /dev/null +++ b/src/routes/api/v1/Tokens.ts @@ -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; diff --git a/src/routes/api/v1/TokensById.ts b/src/routes/api/v1/TokensById.ts new file mode 100644 index 0000000..ed32fa9 --- /dev/null +++ b/src/routes/api/v1/TokensById.ts @@ -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; diff --git a/src/routes/api/v1/index.ts b/src/routes/api/v1/index.ts index e0f345c..002ec7a 100644 --- a/src/routes/api/v1/index.ts +++ b/src/routes/api/v1/index.ts @@ -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(), ]; }