feat: API key management improvements
- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels - Add KeyPlain field to store plaintext API keys for re-viewing - API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal - Copy button with 2s feedback on key list - TokenNew shows full key + copy after creation - Increase key prefix display to 12 characters - Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite - Fix user.status === 'active' checks across frontend views - Add channel store for new channels API - Update Makefile frontend build to work reliably BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
This commit is contained in:
@@ -1,52 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader title="渠道详情" />
|
||||
<BreadcrumbHeader title="Channel Details" />
|
||||
|
||||
<div v-if="key" class="space-y-5">
|
||||
<div v-if="ch" class="space-y-5">
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<form @submit.prevent="updateKey" class="card-body gap-5 p-4 sm:p-6">
|
||||
<form @submit.prevent="updateCh" class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
<label for="ch-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-name" name="name" type="text" v-model="key.name" autocomplete="off" spellcheck="false"
|
||||
<input id="ch-name" name="name" type="text" v-model="ch.name" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-type" class="mb-1 block text-sm font-medium">
|
||||
Type <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-provider" class="mb-1 block text-sm font-medium">
|
||||
Provider
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select id="key-type" name="type" v-model="key.type" required
|
||||
class="select select-bordered w-full pl-10">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="azure">Azure</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="openai-compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
<img :src="apiKeyImageUrl(key.type)" alt="" width="20" height="20"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||
</div>
|
||||
<div class="input input-bordered w-full bg-base-200">{{ ch.provider }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-apikey" class="mb-1 block text-sm font-medium">
|
||||
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-base-url" class="mb-1 block text-sm font-medium">
|
||||
Base URL <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-apikey" name="apikey" type="text" v-model="key.apikey" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full font-mono text-sm" required />
|
||||
<input id="ch-base-url" name="base_url" type="url" v-model="ch.base_url" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||
<input id="key-endpoint" name="endpoint" type="url" v-model="key.endpoint" spellcheck="false"
|
||||
<label for="ch-api-key" class="mb-1 block text-sm font-medium">API Key</label>
|
||||
<input id="ch-api-key" name="api_key" type="password" v-model="api_key" autocomplete="off"
|
||||
spellcheck="false" placeholder="Leave blank to keep current"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,48 +48,36 @@
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||
<input id="key-resource" name="resource_name" type="text" v-model="key.resource_name"
|
||||
autocomplete="off" spellcheck="false" class="input input-bordered w-full" />
|
||||
<label for="ch-priority" class="mb-1 block text-sm font-medium">Priority</label>
|
||||
<input id="ch-priority" name="priority" type="number" v-model.number="ch.priority"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||
<input id="key-secret" name="api_secret" type="text" v-model="key.api_secret" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full font-mono text-sm" />
|
||||
<label for="ch-weight" class="mb-1 block text-sm font-medium">Weight</label>
|
||||
<input id="ch-weight" name="weight" type="number" v-model.number="ch.weight"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||
<input id="key-prefix" name="model_prefix" type="text" v-model="key.model_prefix" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full" />
|
||||
<label for="ch-timeout" class="mb-1 block text-sm font-medium">Timeout (ms)</label>
|
||||
<input id="ch-timeout" name="timeout_ms" type="number" v-model.number="ch.timeout_ms"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||
<textarea id="key-alias" name="model_alias" v-model="key.model_alias" rows="2" placeholder='{}'
|
||||
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
<label for="ch-max-concurrency" class="mb-1 block text-sm font-medium">Max Concurrency</label>
|
||||
<input id="ch-max-concurrency" name="max_concurrency" type="number" v-model.number="ch.max_concurrency"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||
<textarea id="key-parameters" name="parameters" v-model="key.parameters" rows="2" placeholder="{}"
|
||||
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||
<TagInput v-model="key.support_models_array" clearable placeholder="Type a model and press Enter"
|
||||
@change="onchange_supportmodel" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="key.active" role="switch" class="toggle toggle-sm"
|
||||
:class="key.active ? 'toggle-success' : 'toggle-error'" aria-label="Key active" />
|
||||
<input type="checkbox" name="enabled" :checked="ch.enabled" role="switch" class="toggle toggle-sm"
|
||||
:class="ch.enabled ? 'toggle-success' : 'toggle-error'" @change="toggleEnabled" aria-label="Channel enabled" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ key.active ? 'Active' : 'Inactive' }}
|
||||
{{ ch.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +86,7 @@
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Back</button>
|
||||
<button type="button" @click="goBack" class="btn btn-ghost btn-sm">Back</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
|
||||
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
@@ -123,7 +99,7 @@
|
||||
<!-- Loading state -->
|
||||
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading key details">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading channel details">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,76 +108,62 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useKeyStore } from '../../stores/key';
|
||||
import { useChannelStore, type Channel } from '../../stores/channel';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import TagInput from '@/components/common/TagInput.vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const keyStore = useKeyStore();
|
||||
const channelStore = useChannelStore();
|
||||
const { setToast } = useToast();
|
||||
const updating = ref(false);
|
||||
const api_key = ref('');
|
||||
|
||||
const keyId = computed(() => route.query.id);
|
||||
|
||||
const key = computed(() => keyStore.key);
|
||||
const loading = computed(() => keyStore.loading);
|
||||
const channelId = computed(() => route.query.id);
|
||||
const ch = computed(() => channelStore.channel);
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('keyId', keyId.value)
|
||||
if (keyId.value) {
|
||||
await keyStore.fetchKey(keyId.value as string);
|
||||
if (channelId.value) {
|
||||
await channelStore.fetchChannel(channelId.value as string);
|
||||
}
|
||||
});
|
||||
|
||||
const keyOption = reactive([
|
||||
{name: 'openai', label: 'OpenAI'},
|
||||
{name: 'claude', label: 'Claude'},
|
||||
{name: 'gemini', label: 'Gemini'},
|
||||
{name: 'azure', label: 'Azure'},
|
||||
{name: 'github', label: 'Github'},
|
||||
{name: 'openai-compatible', label: 'OpenAI Compatible'}
|
||||
])
|
||||
|
||||
const apiKeyImageMap: Record<string, string> = {
|
||||
'openai': '/assets/openai.svg',
|
||||
'claude': '/assets/claude.svg',
|
||||
'gemini': '/assets/gemini.svg',
|
||||
'azure': '/assets/azure.svg',
|
||||
'github': '/assets/github.svg'
|
||||
const toggleEnabled = () => {
|
||||
if (!ch.value) return;
|
||||
ch.value.enabled = !ch.value.enabled;
|
||||
};
|
||||
|
||||
const apiKeyImageUrl = (keytype: string) => {
|
||||
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
||||
};
|
||||
|
||||
const onchange_supportmodel = () => {
|
||||
if (!key.value) return;
|
||||
key.value.support_models = JSON.stringify(key.value.support_models_array)
|
||||
}
|
||||
|
||||
const updateKey = async () => {
|
||||
if (!key.value) return;
|
||||
const updateCh = async () => {
|
||||
if (!ch.value) return;
|
||||
updating.value = true;
|
||||
try {
|
||||
const res = await keyStore.updateKey(key.value);
|
||||
console.log('updateKey', res)
|
||||
if (res.data?.code == 200) {
|
||||
setToast(`Key ${key.value.name} updated`, 'success');
|
||||
const payload: Partial<Channel> & { api_key?: string } = {
|
||||
name: ch.value.name,
|
||||
base_url: ch.value.base_url,
|
||||
priority: ch.value.priority,
|
||||
weight: ch.value.weight,
|
||||
timeout_ms: ch.value.timeout_ms,
|
||||
max_concurrency: ch.value.max_concurrency,
|
||||
enabled: ch.value.enabled,
|
||||
};
|
||||
if (api_key.value) {
|
||||
payload.api_key = api_key.value;
|
||||
}
|
||||
await keyStore.refreshKey(key.value.id);
|
||||
const res = await channelStore.updateChannel(ch.value.id, payload);
|
||||
if (res.data?.id) {
|
||||
setToast(`Channel ${ch.value.name} updated`, 'success');
|
||||
}
|
||||
await channelStore.fetchChannel(ch.value.id);
|
||||
} catch (err: any) {
|
||||
console.error('Error updating key:', err);
|
||||
console.error('Error updating channel:', err);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'ApiKey' });
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push({ name: 'Channels' });
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user