From 95925b028947f33542d8b798ac235d0488df27da Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Sun, 9 Mar 2025 14:02:00 +0100
Subject: [PATCH] Added support for API tokens.

---
 frontend/src/components/Header.vue            |   7 +
 frontend/src/components/api/Token.vue         | 150 ++++++++++++++++
 frontend/src/components/api/TokenForm.vue     | 142 +++++++++++++++
 frontend/src/components/api/TokensList.vue    |  37 ++++
 .../src/components/devices/DevicesList.vue    |  29 +--
 frontend/src/elements/ConfirmDialog.vue       |  14 +-
 frontend/src/mixins/Api.vue                   |   2 +
 frontend/src/mixins/api/Sessions.vue          |  37 ++++
 frontend/src/models/UserSession.ts            |  25 ++-
 frontend/src/router/index.ts                  |   7 +
 frontend/src/styles/common.scss               |   1 +
 frontend/src/styles/views.scss                | 169 ++++++++++++++++++
 frontend/src/views/API.vue                    |  92 ++++++++++
 frontend/src/views/Devices.vue                |  28 +--
 src/db/migrations/000_initial.ts              |   5 +
 src/db/types/UserSession.ts                   |   9 +
 src/models/UserSession.ts                     |  12 +-
 src/repos/UserSessions.ts                     |  15 ++
 src/routes/api/v1/Tokens.ts                   |  52 ++++++
 src/routes/api/v1/TokensById.ts               |  37 ++++
 src/routes/api/v1/index.ts                    |   4 +
 21 files changed, 804 insertions(+), 70 deletions(-)
 create mode 100644 frontend/src/components/api/Token.vue
 create mode 100644 frontend/src/components/api/TokenForm.vue
 create mode 100644 frontend/src/components/api/TokensList.vue
 create mode 100644 frontend/src/mixins/api/Sessions.vue
 create mode 100644 frontend/src/styles/views.scss
 create mode 100644 frontend/src/views/API.vue
 create mode 100644 src/routes/api/v1/Tokens.ts
 create mode 100644 src/routes/api/v1/TokensById.ts

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" />&nbsp;&nbsp;
+                <span class="item-text">API</span>
+              </RouterLink>
+            </DropdownItem>
+
             <DropdownItem>
               <RouterLink to="/logout">
                 <font-awesome-icon icon="sign-out-alt" />&nbsp;&nbsp;
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" />&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>
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" />&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>
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(),
   ];
 }