<template> <div class="page"> <div class="manager"> <div class="keys-header"> <h2 class="heading">API keys</h2> <div class="keys-actions"> <Icon v-tooltip="'Flush all keys'" icon="stack-cancel" @click="flushKeys" /> <button class="button" @click="createKey" >New key</button> </div> </div> <div v-if="newKey" class="newkey" > <p class="key-info">Successfully generated key with identifier <strong class="newkey-identifier ellipsis">{{ newKey.identifier }}</strong>:</p> <input :value="newKey.key" class="input ellipsis" @click="copyKey" > <p class="key-info">Please store this key securely, you will <strong>not</strong> be able to retrieve it later. If you lose it, you must generate a new key.</p> </div> <ul v-if="keys.length > 0" class="keys nolist" > <li v-for="key in keys" :key="`key-${key.id}`" class="key" > <div class="key-row key-header"> <strong class="key-value key-identifier ellipsis">{{ key.identifier }}</strong> <span class="key-actions"> <Icon icon="bin" @click="removeKey(key)" /> </span> </div> <div class="key-row key-details"> <span class="key-value key-created"> <Icon icon="plus-circle" /> <time v-tooltip="`Created ${format(key.createdAt, 'yyyy-MM-dd hh:mm:ss')}`" :datetime="key.createdAt.toISOString()" >{{ formatDistanceStrict(key.createdAt, now) }} ago</time> </span> <span class="key-value key-used"> <Icon icon="history" /> <template v-if="key.lastUsedAt"> <time v-tooltip="`Last used ${format(key.lastUsedAt, 'yyyy-MM-dd hh:mm:ss')} from IP ${key.lastUsedIp}`" :datetime="key.lastUsedAt.toISOString()" >{{ formatDistanceStrict(key.lastUsedAt, now) }} ago</time> </template> <template v-else>Never</template> </span> </div> </li> </ul> <div v-if="keys.length > 0" class="info" > <h3 class="info-heading">HTTP headers</h3> <code class="headers"> API-User: {{ user.id }}<br> API-Key: YourSecurelyStoredApiKey12345678 </code> </div> </div> </div> </template> <script setup> import { ref, inject } from 'vue'; import { format, formatDistanceStrict } from 'date-fns'; import { get, post, del } from '#/src/api.js'; import events from '#/src/events.js'; const pageContext = inject('pageContext'); const now = pageContext.meta.now; const user = pageContext.user; const keys = ref(pageContext.pageProps.keys); const newKey = ref(null); async function createKey() { const key = await post('/keys', null, { appendErrorMessage: true, }); newKey.value = key; keys.value = await get('/me/keys'); } async function removeKey(key) { if (confirm(`Are you sure you want to remove API key '${key.identifier}'? It can not be restored.`)) { // eslint-disable-line no-restricted-globals, no-alert newKey.value = null; await del(`/me/keys/${key.identifier}`); keys.value = await get('/me/keys'); } } async function flushKeys() { if (confirm('Are you sure you want to remove ALL your API keys? They can not be restored.')) { // eslint-disable-line no-restricted-globals, no-alert newKey.value = null; await del('/me/keys'); keys.value = []; } } function copyKey(event) { event.target.select(); navigator.clipboard.writeText(newKey.value.key); events.emit('feedback', { type: 'success', message: 'Key copied to clipboard', }); } </script> <style scoped> .page { display: flex; flex-grow: 1; justify-content: center; } .manager { width: 1200px; max-width: 100%; box-sizing: border-box; padding: 1rem; } .keys-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; } .keys-actions { display: flex; gap: 1rem; align-items: center; .icon { padding: .5rem 1rem; } } .keys-actions, .key-actions { .icon { height: 100%; fill: var(--glass); &:hover { fill: var(--error); cursor: pointer; } } } .keys { display: grid; grid-template-columns: repeat(auto-fill, minmax(25rem, 1fr)); gap: .5rem; margin-bottom: 2rem; } .key { background: var(--background); box-shadow: 0 0 3px var(--shadow-weak-30); font-size: .9rem; } .key-row { display: flex; justify-content: space-between; overflow: hidden; } .key-value { display: flex; align-items: center; gap: .25rem; box-sizing: border-box; .icon { width: .9rem; height: .9rem; fill: var(--glass-strong-10); } } .key-header .key-value { padding: .5rem .5rem .25rem .5rem; } .key-details .key-value { padding: .25rem .5rem .5rem .5rem; } .key-identifier { display: inline-block; width: 0; flex-grow: 1; } .key-actions .icon { padding: 0 .5rem .5rem .5rem; } .newkey { max-width: 100%; display: inline-block; box-sizing: border-box; padding: .5rem .75rem; margin-bottom: 1rem; background: var(--enabled-background); border: solid 1px var(--success); border-radius: .25rem; line-height: 1.5; .input { width: 24rem; max-width: 100%; padding: .25rem .5rem; margin-bottom: .5rem; font-weight: bold; } } .newkey-identifier { white-space: nowrap; } .key-info { margin: 0; &:not(:last-child) { margin-bottom: .25rem; } } .headers { display: block; max-width: 100%; padding: .5rem 0; white-space: nowrap; overflow: auto; } .info-heading { margin: 0; } @media(--small-20) { .keys { grid-template-columns: 1fr; } } </style>