Newer
Older
navi-1 / webclient / src / components / settings / ApiKeysPanel.vue
@Eugene Sukhodolskiy Eugene Sukhodolskiy 2 days ago 5 KB Add NAVI_AUTH_ENABLED switch for optional auth
<template>
  <div class="api-keys-panel">
    <div class="api-keys-header">
      <h2 class="api-keys-title">API Keys</h2>
      <GnButton variant="primary" icon="ph ph-plus" @click="openCreateModal">
        New Key
      </GnButton>
    </div>

    <p class="api-keys-description">
      API keys allow headless clients (scripts, voice assistants, IoT devices)
      to authenticate without browser-based OAuth.
    </p>

    <div v-if="authStore.isNoAuthMode" class="api-keys-warning">
      <i class="ph ph-warning-circle"></i>
      Authorization is currently disabled. Any keys created here are tied to the
      local anonymous user and grant full access to this Navi instance.
    </div>

    <div v-if="tokensStore.loading" class="api-keys-loading">Loading...</div>

    <div v-else-if="!tokensStore.tokens.length" class="api-keys-empty">
      No API keys yet. Create one to get started.
    </div>

    <div v-else class="api-keys-table-wrapper">
      <div class="api-keys-table">
        <div class="api-keys-row api-keys-header-row">
          <span class="col-name">Name</span>
          <span class="col-prefix">Key</span>
          <span class="col-created">Created</span>
          <span class="col-last-used">Last used</span>
          <span class="col-actions"></span>
        </div>

        <div
          v-for="token in tokensStore.tokens"
          :key="token.id"
          class="api-keys-row">
          <span class="col-name">{{ token.name }}</span>
          <span class="col-prefix">{{ token.token_prefix }}</span>
          <span class="col-created">{{ formatDate(token.created_at) }}</span>
          <span class="col-last-used">{{ formatDate(token.last_used_at) || 'Never' }}</span>
          <span class="col-actions">
            <GnIconButton
              icon="ph ph-trash"
              label="Revoke"
              variant="ghost"
              @click="onRevoke(token.id, token.name)"
            />
          </span>
        </div>
      </div>
    </div>

    <CreateKeyModal
      :open="createModalOpen"
      :creating="tokensStore.creating"
      @close="createModalOpen = false"
      @submit="onCreate"
    />

    <ShowTokenModal
      :open="showTokenModal"
      :token="createdToken"
      @close="showTokenModal = false"
    />
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useApiTokensStore } from '@/stores/apiTokens.js'
import { useAuthStore } from '@/stores/auth.js'
import { useConfirm } from '@/composables/useConfirm.js'
import { useToast } from 'gnexus-ui-kit/vue'
import CreateKeyModal from './CreateKeyModal.vue'
import ShowTokenModal from './ShowTokenModal.vue'

const tokensStore = useApiTokensStore()
const authStore = useAuthStore()
const confirm = useConfirm()
const toast = useToast()

const createModalOpen = ref(false)
const showTokenModal = ref(false)
const createdToken = ref('')

onMounted(() => {
  tokensStore.fetchTokens()
})

function openCreateModal() {
  createModalOpen.value = true
}

async function onCreate(name) {
  try {
    const token = await tokensStore.createToken(name)
    createModalOpen.value = false
    createdToken.value = token.token
    showTokenModal.value = true
  } catch (err) {
    toast.error({ title: 'Failed to create key', text: err.message })
  }
}

async function onRevoke(id, name) {
  const ok = await confirm(`Revoke API key "${name}"? This cannot be undone.`)
  if (!ok) return
  try {
    await tokensStore.revokeToken(id)
    toast.success({ title: 'API key revoked' })
  } catch (err) {
    toast.error({ title: 'Failed to revoke key', text: err.message })
  }
}

function formatDate(iso) {
  if (!iso) return null
  const d = new Date(iso)
  return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
</script>

<style scoped lang="scss">
.api-keys-panel {
  max-width: 800px;
}

.api-keys-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}

.api-keys-title {
  font-size: 1.1rem;
  font-weight: 600;
  margin: 0;
  color: var(--color-text, #a9b1d6);
}

.api-keys-description {
  margin: 0 0 16px;
  font-size: 0.875rem;
  color: var(--color-text-secondary, #a9b1d6);
}

.api-keys-loading,
.api-keys-empty {
  padding: 24px;
  text-align: center;
  color: var(--color-text-dark, #787c99);
  font-size: 0.9rem;
}

.api-keys-warning {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding: 12px;
  margin-bottom: 16px;
  border: 1px solid var(--color-warning, #e0af68);
  border-radius: 8px;
  color: var(--color-text-secondary, #a9b1d6);
  font-size: 0.875rem;

  i {
    color: var(--color-warning, #e0af68);
    font-size: 16px;
    flex-shrink: 0;
  }
}

.api-keys-row {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr 1fr auto;
  gap: 12px;
  align-items: center;
  padding: 10px 12px;
  font-size: 0.875rem;
  border-bottom: 1px solid var(--color-border, #3b4261);
}

.api-keys-header-row {
  font-weight: 600;
  color: var(--color-text-secondary, #a9b1d6);
  border-bottom-width: 2px;
}

.col-name {
  color: var(--color-text, #a9b1d6);
}

.col-prefix {
  font-family: monospace;
  color: var(--color-text-secondary, #a9b1d6);
}

.col-created,
.col-last-used {
  color: var(--color-text-dark, #787c99);
}

.col-actions {
  text-align: right;
}
</style>