mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-04-05 17:15:40 +00:00
471 lines
14 KiB
Vue
471 lines
14 KiB
Vue
<template>
|
||
<div>
|
||
<!-- Create new key -->
|
||
<os-card class="ds-mb-large">
|
||
<h2 class="title">{{ $t('settings.api-keys.create.title') }}</h2>
|
||
<form @submit.prevent="createKey">
|
||
<div class="ds-mb-small">
|
||
<ocelot-input
|
||
id="api-key-name"
|
||
model="name"
|
||
:value="name"
|
||
:label="$t('settings.api-keys.create.name-label')"
|
||
@input="name = $event"
|
||
/>
|
||
</div>
|
||
<div class="ds-mb-small">
|
||
<label class="ds-label" for="api-key-expiry">
|
||
{{ $t('settings.api-keys.create.expiry-label') }}
|
||
</label>
|
||
<select id="api-key-expiry" v-model="expiresInDays" class="settings-select">
|
||
<option :value="null">{{ $t('settings.api-keys.create.expiry-never') }}</option>
|
||
<option :value="30">
|
||
{{ $t('settings.api-keys.create.expiry-days', { days: 30 }) }}
|
||
</option>
|
||
<option :value="90">
|
||
{{ $t('settings.api-keys.create.expiry-days', { days: 90 }) }}
|
||
</option>
|
||
<option :value="180">
|
||
{{ $t('settings.api-keys.create.expiry-days', { days: 180 }) }}
|
||
</option>
|
||
<option :value="365">
|
||
{{ $t('settings.api-keys.create.expiry-days', { days: 365 }) }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
<p v-if="activeKeys.length >= maxKeys" class="ds-text ds-text-small limit-warning">
|
||
{{ $t('settings.api-keys.create.limit-reached', { max: maxKeys }) }}
|
||
</p>
|
||
<os-button
|
||
variant="primary"
|
||
type="submit"
|
||
data-test="create-api-key-submit"
|
||
:disabled="!name.trim() || activeKeys.length >= maxKeys"
|
||
:loading="creating"
|
||
>
|
||
{{ $t('settings.api-keys.create.submit') }}
|
||
</os-button>
|
||
</form>
|
||
</os-card>
|
||
|
||
<!-- Secret banner (shown once after creation) -->
|
||
<os-card v-if="newSecret" class="ds-mb-large secret-banner">
|
||
<h3 class="title">{{ $t('settings.api-keys.secret.title') }}</h3>
|
||
<div class="secret-display">
|
||
<code class="secret-code">{{ newSecret }}</code>
|
||
<os-button
|
||
v-if="canCopy"
|
||
variant="primary"
|
||
appearance="outline"
|
||
size="sm"
|
||
@click="copySecret"
|
||
>
|
||
<template #icon><os-icon :icon="icons.copy" /></template>
|
||
{{ $t('settings.api-keys.secret.copy') }}
|
||
</os-button>
|
||
</div>
|
||
<p class="ds-text ds-text-small secret-warning">
|
||
{{ $t('settings.api-keys.secret.warning') }}
|
||
</p>
|
||
</os-card>
|
||
|
||
<!-- Active keys -->
|
||
<os-card class="ds-mb-large">
|
||
<h2 class="title">
|
||
{{ $t('settings.api-keys.list.title') }}
|
||
<span class="key-counter">({{ activeKeys.length }}/{{ maxKeys }})</span>
|
||
</h2>
|
||
<div v-if="activeKeys.length" class="ds-table-wrap">
|
||
<table class="ds-table ds-table-condensed ds-table-bordered">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.name') }}
|
||
</th>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.prefix') }}
|
||
</th>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.expires') }}
|
||
</th>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.last-used') }}
|
||
</th>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.actions') }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="key in activeKeys" :key="key.id">
|
||
<td
|
||
class="ds-table-col"
|
||
:title="
|
||
$t('settings.api-keys.list.created-at') +
|
||
': ' +
|
||
$options.filters.dateTime(key.createdAt)
|
||
"
|
||
>
|
||
{{ key.name }}
|
||
</td>
|
||
<td
|
||
class="ds-table-col"
|
||
:title="
|
||
$t('settings.api-keys.list.created-at') +
|
||
': ' +
|
||
$options.filters.dateTime(key.createdAt)
|
||
"
|
||
>
|
||
<code>{{ key.keyPrefix }}...</code>
|
||
</td>
|
||
<td class="ds-table-col">
|
||
<template v-if="isExpired(key)">
|
||
<span class="status-label">{{ $t('settings.api-keys.list.expired') }}</span>
|
||
</template>
|
||
<template v-else-if="key.expiresAt">
|
||
<date-time :date-time="key.expiresAt" />
|
||
</template>
|
||
<template v-else>
|
||
{{ $t('settings.api-keys.list.never-expires') }}
|
||
</template>
|
||
</td>
|
||
<td class="ds-table-col">
|
||
<date-time v-if="key.lastUsedAt" :date-time="key.lastUsedAt" />
|
||
<template v-else>{{ $t('settings.api-keys.list.never') }}</template>
|
||
</td>
|
||
<td class="ds-table-col">
|
||
<os-button
|
||
variant="danger"
|
||
appearance="outline"
|
||
circle
|
||
size="sm"
|
||
:loading="revokingKeyId === key.id"
|
||
:aria-label="$t('settings.api-keys.list.revoke')"
|
||
data-test="revoke-api-key"
|
||
@click="confirmRevoke(key)"
|
||
>
|
||
<template #icon><os-icon :icon="icons.trash" /></template>
|
||
</os-button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div v-else class="ds-placeholder">
|
||
{{ $t('settings.api-keys.list.empty') }}
|
||
</div>
|
||
|
||
<!-- Revoked keys (collapsible) -->
|
||
<div v-if="revokedKeys.length" class="revoked-section">
|
||
<button
|
||
class="revoked-toggle"
|
||
:aria-expanded="String(showRevoked)"
|
||
aria-controls="revoked-keys-list"
|
||
@click="showRevoked = !showRevoked"
|
||
>
|
||
<span>
|
||
{{ $t('settings.api-keys.revoked-list.title', { count: revokedKeys.length }) }}
|
||
</span>
|
||
<span class="revoked-chevron" :class="{ open: showRevoked }">▼</span>
|
||
</button>
|
||
<div v-if="showRevoked" id="revoked-keys-list" class="ds-table-wrap">
|
||
<table class="ds-table ds-table-condensed ds-table-bordered revoked-table">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.name') }}
|
||
</th>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.prefix') }}
|
||
</th>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.revoked-list.revoked-at') }}
|
||
</th>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.last-used') }}
|
||
</th>
|
||
<th scope="col" class="ds-table-head-col">
|
||
{{ $t('settings.api-keys.list.actions') }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="key in revokedKeys" :key="key.id">
|
||
<td
|
||
class="ds-table-col"
|
||
:title="
|
||
$t('settings.api-keys.list.created-at') +
|
||
': ' +
|
||
$options.filters.dateTime(key.createdAt)
|
||
"
|
||
>
|
||
{{ key.name }}
|
||
</td>
|
||
<td
|
||
class="ds-table-col"
|
||
:title="
|
||
$t('settings.api-keys.list.created-at') +
|
||
': ' +
|
||
$options.filters.dateTime(key.createdAt)
|
||
"
|
||
>
|
||
<code>{{ key.keyPrefix }}...</code>
|
||
</td>
|
||
<td class="ds-table-col">
|
||
<date-time v-if="key.disabledAt" :date-time="key.disabledAt" />
|
||
<template v-else>–</template>
|
||
</td>
|
||
<td class="ds-table-col">
|
||
<date-time v-if="key.lastUsedAt" :date-time="key.lastUsedAt" />
|
||
<template v-else>{{ $t('settings.api-keys.list.never') }}</template>
|
||
</td>
|
||
<td class="ds-table-col">
|
||
<span class="status-label">{{ $t('settings.api-keys.list.revoked') }}</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</os-card>
|
||
|
||
<!-- Confirm revoke modal -->
|
||
<confirm-modal
|
||
v-if="showRevokeModal"
|
||
:modalData="revokeModalData"
|
||
@close="showRevokeModal = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { OsButton, OsCard, OsIcon } from '@ocelot-social/ui'
|
||
import { iconRegistry } from '~/utils/iconRegistry'
|
||
import OcelotInput from '~/components/OcelotInput/OcelotInput'
|
||
import ConfirmModal from '~/components/Modal/ConfirmModal'
|
||
import DateTime from '~/components/DateTime'
|
||
import {
|
||
myApiKeysQuery,
|
||
createApiKeyMutation,
|
||
revokeApiKeyMutation,
|
||
} from '~/graphql/settings/ApiKeys'
|
||
import scrollToContent from './scroll-to-content.js'
|
||
|
||
export default {
|
||
mixins: [scrollToContent],
|
||
components: {
|
||
OsButton,
|
||
OsCard,
|
||
OsIcon,
|
||
OcelotInput,
|
||
ConfirmModal,
|
||
DateTime,
|
||
},
|
||
created() {
|
||
this.icons = iconRegistry
|
||
this.canCopy = typeof navigator !== 'undefined' && !!navigator.clipboard
|
||
},
|
||
data() {
|
||
return {
|
||
myApiKeys: [],
|
||
name: '',
|
||
expiresInDays: null,
|
||
creating: false,
|
||
newSecret: null,
|
||
canCopy: false,
|
||
revokingKeyId: null,
|
||
showRevokeModal: false,
|
||
revokeModalData: null,
|
||
showRevoked: false,
|
||
}
|
||
},
|
||
computed: {
|
||
maxKeys() {
|
||
return this.$env.API_KEYS_MAX_PER_USER || 5
|
||
},
|
||
activeKeys() {
|
||
return (this.myApiKeys || []).filter((k) => !k.disabled)
|
||
},
|
||
revokedKeys() {
|
||
return (this.myApiKeys || []).filter((k) => k.disabled)
|
||
},
|
||
},
|
||
apollo: {
|
||
myApiKeys: { query: myApiKeysQuery(), fetchPolicy: 'cache-and-network' },
|
||
},
|
||
methods: {
|
||
async createKey() {
|
||
if (!this.name.trim()) return
|
||
this.creating = true
|
||
try {
|
||
const variables = { name: this.name.trim() }
|
||
if (this.expiresInDays) {
|
||
variables.expiresInDays = Number(this.expiresInDays)
|
||
}
|
||
const result = await this.$apollo.mutate({
|
||
mutation: createApiKeyMutation(),
|
||
variables,
|
||
})
|
||
this.newSecret = result.data.createApiKey.secret
|
||
this.name = ''
|
||
this.expiresInDays = null
|
||
this.$apollo.queries.myApiKeys.refetch()
|
||
this.$toast.success(this.$t('settings.api-keys.create.success'))
|
||
} catch (error) {
|
||
this.$toast.error(error.message)
|
||
} finally {
|
||
this.creating = false
|
||
}
|
||
},
|
||
async copySecret() {
|
||
try {
|
||
await navigator.clipboard.writeText(this.newSecret)
|
||
this.$toast.success(this.$t('settings.api-keys.secret.copied'))
|
||
} catch {
|
||
this.$toast.error(this.$t('settings.api-keys.secret.copy-failed'))
|
||
}
|
||
},
|
||
confirmRevoke(key) {
|
||
this.revokeModalData = {
|
||
titleIdent: 'settings.api-keys.revoke.title',
|
||
messageIdent: 'settings.api-keys.revoke.message',
|
||
messageParams: { name: key.name },
|
||
buttons: {
|
||
confirm: {
|
||
danger: true,
|
||
icon: this.icons.trash,
|
||
textIdent: 'settings.api-keys.revoke.confirm',
|
||
callback: () => this.revokeKey(key),
|
||
},
|
||
cancel: {
|
||
icon: this.icons.close,
|
||
textIdent: 'actions.cancel',
|
||
callback: () => {},
|
||
},
|
||
},
|
||
}
|
||
this.showRevokeModal = true
|
||
},
|
||
async revokeKey(key) {
|
||
this.revokingKeyId = key.id
|
||
try {
|
||
await this.$apollo.mutate({
|
||
mutation: revokeApiKeyMutation(),
|
||
variables: { id: key.id },
|
||
})
|
||
this.$apollo.queries.myApiKeys.refetch()
|
||
this.$toast.success(this.$t('settings.api-keys.revoke.success', { name: key.name }))
|
||
} catch (error) {
|
||
this.$toast.error(error.message)
|
||
throw error
|
||
} finally {
|
||
this.revokingKeyId = null
|
||
}
|
||
},
|
||
isExpired(key) {
|
||
return key.expiresAt && new Date(key.expiresAt) < new Date()
|
||
},
|
||
},
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.secret-banner {
|
||
background-color: $color-warning-inverse;
|
||
border: 1px solid $color-warning;
|
||
}
|
||
|
||
.secret-display {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $space-x-small;
|
||
margin: $space-x-small 0;
|
||
}
|
||
|
||
.secret-code {
|
||
flex: 1;
|
||
padding: $space-x-small;
|
||
background: $background-color-base;
|
||
border: 1px solid $color-neutral-80;
|
||
border-radius: $border-radius-base;
|
||
word-break: break-all;
|
||
font-size: $font-size-small;
|
||
}
|
||
|
||
.secret-warning {
|
||
color: $color-warning;
|
||
font-style: italic;
|
||
}
|
||
|
||
.key-counter {
|
||
font-weight: normal;
|
||
font-size: $font-size-base;
|
||
color: $color-neutral-50;
|
||
}
|
||
|
||
.limit-warning {
|
||
color: $color-warning;
|
||
margin-bottom: $space-x-small;
|
||
}
|
||
|
||
.status-label {
|
||
font-size: $font-size-small;
|
||
color: $text-color-soft;
|
||
font-style: italic;
|
||
}
|
||
|
||
.revoked-section {
|
||
margin-top: $space-large;
|
||
}
|
||
|
||
.revoked-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $space-x-small;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: $text-color-soft;
|
||
padding: $space-x-small 0;
|
||
font-size: $font-size-base;
|
||
|
||
&:hover {
|
||
color: $text-color-base;
|
||
}
|
||
}
|
||
|
||
.revoked-chevron {
|
||
font-size: $font-size-small;
|
||
transition: transform 0.2s;
|
||
|
||
&.open {
|
||
transform: rotate(180deg);
|
||
}
|
||
}
|
||
|
||
.ds-table-wrap {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
tr > th:last-child,
|
||
tr > td:last-child {
|
||
position: sticky;
|
||
right: 0;
|
||
background-color: $background-color-base;
|
||
text-align: center;
|
||
}
|
||
|
||
.revoked-table {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.settings-select {
|
||
width: 100%;
|
||
padding: $space-x-small;
|
||
font-size: $font-size-base;
|
||
border: 1px solid $color-neutral-80;
|
||
border-radius: $border-radius-base;
|
||
background-color: $background-color-base;
|
||
}
|
||
</style>
|