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:
287
src/components/common/TokenManager.vue
Normal file
287
src/components/common/TokenManager.vue
Normal 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>
|
||||
@@ -39,6 +39,11 @@ function logout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function goToSettingsTokens() {
|
||||
showUserMenu.value = false
|
||||
router.push('/settings/tokens')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,6 +109,14 @@ function logout() {
|
||||
<small>{{ authStore.user?.role }}</small>
|
||||
</div>
|
||||
<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">
|
||||
<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"/>
|
||||
|
||||
@@ -32,6 +32,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/DocumentView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/settings/tokens',
|
||||
name: 'settings-tokens',
|
||||
component: () => import('@/views/SettingsTokens.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/dashboard'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
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 router from '@/router'
|
||||
|
||||
@@ -58,6 +58,23 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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 {
|
||||
token,
|
||||
user,
|
||||
@@ -65,6 +82,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
login,
|
||||
register,
|
||||
fetchUser,
|
||||
logout
|
||||
logout,
|
||||
fetchTokens,
|
||||
generateToken,
|
||||
revokeToken
|
||||
}
|
||||
})
|
||||
|
||||
@@ -234,3 +234,24 @@ export interface TagForm {
|
||||
name: 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
|
||||
}
|
||||
|
||||
411
src/views/SettingsTokens.vue
Normal file
411
src/views/SettingsTokens.vue
Normal 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>
|
||||
Reference in New Issue
Block a user