feat: Add Token Management UI

- Add SettingsTokens view at /settings/tokens
- Add TokenManager modal component for creating tokens
- Add token management functions to auth store (fetchTokens, generateToken, revokeToken)
- Add Settings link in header user dropdown
- Add ApiToken types to types/index.ts
- Route: GET /auth/tokens, POST /auth/token/generate, DELETE /auth/tokens/{id}
This commit is contained in:
Hiro
2026-03-31 01:43:50 +00:00
parent bc9862eb7d
commit fe6ef71902
6 changed files with 760 additions and 2 deletions

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import type { TokenRole, ApiTokenGenerateResponse } from '@/types'
import Button from '@/components/common/Button.vue'
import Modal from '@/components/common/Modal.vue'
interface Props {
show: boolean
}
defineProps<Props>()
const emit = defineEmits<{
close: []
tokensChanged: []
}>()
const authStore = useAuthStore()
const step = ref<'form' | 'result'>('form')
const tokenName = ref('')
const tokenRole = ref<TokenRole>('viewer')
const creating = ref(false)
const generatedToken = ref<ApiTokenGenerateResponse | null>(null)
const copied = ref(false)
const error = ref('')
const roles: { value: TokenRole; label: string }[] = [
{ value: 'researcher', label: 'Researcher' },
{ value: 'developer', label: 'Developer' },
{ value: 'viewer', label: 'Viewer' }
]
function reset() {
step.value = 'form'
tokenName.value = ''
tokenRole.value = 'viewer'
creating.value = false
generatedToken.value = null
copied.value = false
error.value = ''
}
function handleClose() {
reset()
emit('close')
}
async function createToken() {
if (!tokenName.value.trim()) {
error.value = 'Token name is required'
return
}
creating.value = true
error.value = ''
try {
generatedToken.value = await authStore.generateToken({
name: tokenName.value.trim(),
role: tokenRole.value
})
step.value = 'result'
emit('tokensChanged')
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to create token'
} finally {
creating.value = false
}
}
async function copyToken() {
if (!generatedToken.value) return
try {
await navigator.clipboard.writeText(generatedToken.value.token)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = generatedToken.value.token
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
}
}
</script>
<template>
<Modal :show="show" title="Create API Token" size="sm" @close="handleClose">
<!-- Form Step -->
<form v-if="step === 'form'" @submit.prevent="createToken">
<div class="form__field">
<label for="token-name" class="form__label">Token Name</label>
<input
id="token-name"
v-model="tokenName"
type="text"
class="form__input"
placeholder="e.g., Production API Key"
autofocus
/>
</div>
<div class="form__field">
<label for="token-role" class="form__label">Role</label>
<select id="token-role" v-model="tokenRole" class="form__select">
<option v-for="role in roles" :key="role.value" :value="role.value">
{{ role.label }}
</option>
</select>
</div>
<p v-if="error" class="form__error">{{ error }}</p>
</form>
<!-- Result Step -->
<div v-else-if="step === 'result' && generatedToken" class="token-result">
<div class="token-result__warning">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<p>Make sure to copy your token now. <strong>You won't be able to see it again!</strong></p>
</div>
<div class="token-result__field">
<label class="form__label">Your API Token</label>
<div class="token-result__token">
<code>{{ generatedToken.token }}</code>
<button class="token-result__copy" @click="copyToken" type="button">
<svg v-if="!copied" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
</div>
<template #footer>
<Button v-if="step === 'form'" variant="secondary" @click="handleClose">Cancel</Button>
<Button v-if="step === 'form'" variant="primary" :loading="creating" @click="createToken">
Generate Token
</Button>
<Button v-else variant="primary" @click="handleClose">Done</Button>
</template>
</Modal>
</template>
<style scoped>
.form__field {
margin-bottom: 1.25rem;
}
.form__field:last-child {
margin-bottom: 0;
}
.form__label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.form__input,
.form__select {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9375rem;
font-family: inherit;
color: var(--text-primary);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.form__input::placeholder {
color: var(--text-secondary);
}
.form__input:focus,
.form__select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.form__select {
cursor: pointer;
}
.form__error {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: #ef4444;
}
/* Token Result Styles */
.token-result__warning {
display: flex;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
margin-bottom: 1.25rem;
color: #92400e;
}
.token-result__warning svg {
flex-shrink: 0;
margin-top: 0.125rem;
}
.token-result__warning p {
margin: 0;
font-size: 0.875rem;
line-height: 1.4;
}
.token-result__warning strong {
font-weight: 600;
}
.token-result__field {
margin-bottom: 0;
}
.token-result__token {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
}
.token-result__token code {
flex: 1;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: var(--text-primary);
word-break: break-all;
}
.token-result__copy {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8125rem;
font-family: inherit;
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s;
}
.token-result__copy:hover {
background: var(--accent);
border-color: var(--accent);
color: white;
}
</style>

View File

@@ -39,6 +39,11 @@ function logout() {
authStore.logout() authStore.logout()
router.push('/login') router.push('/login')
} }
function goToSettingsTokens() {
showUserMenu.value = false
router.push('/settings/tokens')
}
</script> </script>
<template> <template>
@@ -104,6 +109,14 @@ function logout() {
<small>{{ authStore.user?.role }}</small> <small>{{ authStore.user?.role }}</small>
</div> </div>
<hr class="header__dropdown-divider" /> <hr class="header__dropdown-divider" />
<button class="header__dropdown-item" @click="goToSettingsTokens">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
API Tokens
</button>
<hr class="header__dropdown-divider" />
<button class="header__dropdown-item" @click="logout"> <button class="header__dropdown-item" @click="logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>

View File

@@ -32,6 +32,12 @@ const router = createRouter({
component: () => import('@/views/DocumentView.vue'), component: () => import('@/views/DocumentView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/settings/tokens',
name: 'settings-tokens',
component: () => import('@/views/SettingsTokens.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
redirect: '/dashboard' redirect: '/dashboard'

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Agent, AuthResponse } from '@/types' import type { Agent, AuthResponse, ApiToken, ApiTokenGenerateResponse, ApiTokenCreate } from '@/types'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import router from '@/router' import router from '@/router'
@@ -58,6 +58,23 @@ export const useAuthStore = defineStore('auth', () => {
fetchUser() fetchUser()
} }
async function fetchTokens(): Promise<ApiToken[]> {
if (!token.value) return []
try {
return await api.get<ApiToken[]>('/auth/tokens')
} catch {
return []
}
}
async function generateToken(data: ApiTokenCreate): Promise<ApiTokenGenerateResponse> {
return await api.post<ApiTokenGenerateResponse>('/auth/token/generate', data)
}
async function revokeToken(tokenId: string): Promise<void> {
await api.delete(`/auth/tokens/${tokenId}`)
}
return { return {
token, token,
user, user,
@@ -65,6 +82,9 @@ export const useAuthStore = defineStore('auth', () => {
login, login,
register, register,
fetchUser, fetchUser,
logout logout,
fetchTokens,
generateToken,
revokeToken
} }
}) })

View File

@@ -234,3 +234,24 @@ export interface TagForm {
name: string name: string
color: string color: string
} }
// API Token types
export type TokenRole = 'researcher' | 'developer' | 'viewer'
export interface ApiToken {
id: string
name: string
role: TokenRole
created_at: string
}
export interface ApiTokenGenerateResponse {
token: string
name: string
role: TokenRole
}
export interface ApiTokenCreate {
name: string
role: TokenRole
}

View File

@@ -0,0 +1,411 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import type { ApiToken } from '@/types'
import Button from '@/components/common/Button.vue'
import Modal from '@/components/common/Modal.vue'
import TokenManager from '@/components/common/TokenManager.vue'
const authStore = useAuthStore()
const tokens = ref<ApiToken[]>([])
const loading = ref(true)
const showCreateModal = ref(false)
const showRevokeModal = ref(false)
const tokenToRevoke = ref<ApiToken | null>(null)
const revoking = ref(false)
const toastMessage = ref('')
const showToast = ref(false)
onMounted(async () => {
await fetchTokens()
})
async function fetchTokens() {
loading.value = true
try {
tokens.value = await authStore.fetchTokens()
} catch (e) {
console.error('Failed to fetch tokens:', e)
} finally {
loading.value = false
}
}
function confirmRevoke(token: ApiToken) {
tokenToRevoke.value = token
showRevokeModal.value = true
}
async function revokeToken() {
if (!tokenToRevoke.value) return
revoking.value = true
try {
await authStore.revokeToken(tokenToRevoke.value.id)
tokens.value = tokens.value.filter(t => t.id !== tokenToRevoke.value!.id)
showRevokeModal.value = false
tokenToRevoke.value = null
showToastMessage('Token revoked successfully')
} catch (e) {
console.error('Failed to revoke token:', e)
} finally {
revoking.value = false
}
}
function showToastMessage(message: string) {
toastMessage.value = message
showToast.value = true
setTimeout(() => {
showToast.value = false
}, 3000)
}
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function getRoleBadgeClass(role: string) {
switch (role) {
case 'researcher':
return 'badge--blue'
case 'developer':
return 'badge--green'
case 'viewer':
return 'badge--gray'
default:
return ''
}
}
</script>
<template>
<div class="settings-tokens">
<div class="settings-tokens__header">
<div>
<h1 class="settings-tokens__title">API Tokens</h1>
<p class="settings-tokens__subtitle">
Manage your personal API tokens for programmatic access
</p>
</div>
<Button variant="primary" @click="showCreateModal = true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create Token
</Button>
</div>
<!-- Loading State -->
<div v-if="loading" class="settings-tokens__loading">
<div class="settings-tokens__spinner"></div>
<p>Loading tokens...</p>
</div>
<!-- Empty State -->
<div v-else-if="tokens.length === 0" class="settings-tokens__empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
<h2>No API tokens yet</h2>
<p>Create your first API token to access Claudia Docs programmatically</p>
<Button variant="primary" @click="showCreateModal = true">
Create Your First Token
</Button>
</div>
<!-- Tokens Table -->
<div v-else class="settings-tokens__table-wrapper">
<table class="settings-tokens__table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="token in tokens" :key="token.id">
<td class="token-name">{{ token.name }}</td>
<td>
<span :class="['badge', getRoleBadgeClass(token.role)]">
{{ token.role }}
</span>
</td>
<td class="token-date">{{ formatDate(token.created_at) }}</td>
<td>
<button class="revoke-btn" @click="confirmRevoke(token)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Revoke
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Token Modal -->
<TokenManager
:show="showCreateModal"
@close="showCreateModal = false"
@tokens-changed="fetchTokens"
/>
<!-- Revoke Confirmation Modal -->
<Modal :show="showRevokeModal" title="Revoke API Token" size="sm" @close="showRevokeModal = false">
<p class="revoke-confirm__text">
Are you sure you want to revoke the token <strong>"{{ tokenToRevoke?.name }}"</strong>?
This action cannot be undone and any applications using this token will lose access.
</p>
<template #footer>
<Button variant="secondary" @click="showRevokeModal = false">Cancel</Button>
<Button variant="danger" :loading="revoking" @click="revokeToken">Revoke Token</Button>
</template>
</Modal>
<!-- Toast -->
<Transition name="toast">
<div v-if="showToast" class="toast">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ toastMessage }}
</div>
</Transition>
</div>
</template>
<style scoped>
.settings-tokens {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.settings-tokens__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
}
.settings-tokens__title {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
}
.settings-tokens__subtitle {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
color: var(--text-secondary);
}
.settings-tokens__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem;
color: var(--text-secondary);
}
.settings-tokens__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.settings-tokens__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem 2rem;
text-align: center;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px dashed var(--border);
border-radius: 12px;
}
.settings-tokens__empty h2 {
margin: 0;
font-size: 1.25rem;
color: var(--text-primary);
}
.settings-tokens__empty p {
margin: 0;
max-width: 320px;
}
.settings-tokens__table-wrapper {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.settings-tokens__table {
width: 100%;
border-collapse: collapse;
}
.settings-tokens__table th,
.settings-tokens__table td {
padding: 1rem 1.25rem;
text-align: left;
}
.settings-tokens__table th {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.settings-tokens__table td {
font-size: 0.9375rem;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
.settings-tokens__table tbody tr:last-child td {
border-bottom: none;
}
.settings-tokens__table tbody tr:hover {
background: var(--bg-secondary);
}
.token-name {
font-weight: 500;
}
.token-date {
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Badge Styles */
.badge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
}
.badge--blue {
background: #dbeafe;
color: #1d4ed8;
}
.badge--green {
background: #dcfce7;
color: #16a34a;
}
.badge--gray {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
/* Revoke Button */
.revoke-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: none;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8125rem;
font-family: inherit;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.revoke-btn:hover {
background: #fef2f2;
border-color: #ef4444;
color: #ef4444;
}
/* Revoke Confirmation */
.revoke-confirm__text {
margin: 0;
font-size: 0.9375rem;
line-height: 1.6;
color: var(--text-primary);
}
.revoke-confirm__text strong {
font-weight: 600;
}
/* Toast */
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.25rem;
background: var(--text-primary);
color: var(--bg-primary);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
z-index: 1000;
}
.toast svg {
color: #22c55e;
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
</style>