394 lines
12 KiB
Vue
394 lines
12 KiB
Vue
<template>
|
|
<div class="developer-settings">
|
|
<n-space vertical :size="24">
|
|
<!-- API Access 卡片 -->
|
|
<n-card :title="t('API Access')" class="settings-card">
|
|
<n-space vertical :size="20">
|
|
<div class="flex items-center justify-between flex-wrap gap-4">
|
|
<div class="text-base text-gray-700">
|
|
{{ t('API key and API secret can be used to access') }}
|
|
<a href="/docs/api" class="text-primary underline" target="_blank">
|
|
{{ t('Jingrow API') }}
|
|
</a>
|
|
</div>
|
|
<n-button type="primary" @click="showCreateSecretDialog = true" :size="buttonSize">
|
|
{{ apiKeyButtonLabel }}
|
|
</n-button>
|
|
</div>
|
|
<div v-if="apiKey">
|
|
<ClickToCopyField :textContent="apiKey" />
|
|
</div>
|
|
<div v-else class="text-base text-gray-600">
|
|
{{ t("You don't have an API key yet. Click the button above to create one.") }}
|
|
</div>
|
|
</n-space>
|
|
</n-card>
|
|
|
|
<!-- SSH Keys 卡片 -->
|
|
<n-card :title="t('SSH Keys')" class="settings-card">
|
|
<n-space vertical :size="16">
|
|
<n-button type="primary" @click="showAddSSHKeyDialog = true" :size="buttonSize">
|
|
<template #icon>
|
|
<n-icon><Icon icon="tabler:plus" /></n-icon>
|
|
</template>
|
|
{{ t('Add SSH Key') }}
|
|
</n-button>
|
|
|
|
<n-data-table
|
|
v-if="sshKeys.length > 0"
|
|
:columns="sshKeyColumns"
|
|
:data="sshKeys"
|
|
:loading="loadingSSHKeys"
|
|
:pagination="false"
|
|
/>
|
|
<n-empty v-else :description="t('No SSH keys')" />
|
|
</n-space>
|
|
</n-card>
|
|
</n-space>
|
|
|
|
<!-- API Key 创建/重新生成弹窗 -->
|
|
<n-modal
|
|
v-model:show="showCreateSecretDialog"
|
|
preset="card"
|
|
:title="t('API Access')"
|
|
:style="modalStyle"
|
|
:mask-closable="true"
|
|
:close-on-esc="true"
|
|
>
|
|
<template #header>
|
|
<span class="text-lg font-semibold">{{ t('API Access') }}</span>
|
|
</template>
|
|
<n-space vertical :size="20">
|
|
<div v-if="apiSecretData">
|
|
<n-alert type="warning" class="mb-4">
|
|
{{ t('Please copy the API key immediately. You will not be able to view it again!') }}
|
|
</n-alert>
|
|
<n-form-item :label="t('API Key')">
|
|
<ClickToCopyField :textContent="apiSecretData.api_key" />
|
|
</n-form-item>
|
|
<n-form-item :label="t('API Secret')">
|
|
<ClickToCopyField :textContent="apiSecretData.api_secret" />
|
|
</n-form-item>
|
|
</div>
|
|
<div v-else class="text-base text-gray-700">
|
|
{{ t('API key and API secret can be used to access') }}
|
|
<a href="/docs/api" class="text-primary underline" target="_blank">
|
|
{{ t('Jingrow API') }}
|
|
</a>.
|
|
</div>
|
|
</n-space>
|
|
<template #action>
|
|
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
|
|
<n-button @click="handleCloseCreateSecretDialog" :block="isMobile" :size="buttonSize">
|
|
{{ t('Cancel') }}
|
|
</n-button>
|
|
<n-button
|
|
type="primary"
|
|
:loading="creatingSecret"
|
|
:disabled="!!apiSecretData"
|
|
@click="handleCreateSecret"
|
|
:block="isMobile"
|
|
:size="buttonSize"
|
|
>
|
|
{{ apiKey ? t('Regenerate API Key') : t('Create New API Key') }}
|
|
</n-button>
|
|
</n-space>
|
|
</template>
|
|
</n-modal>
|
|
|
|
<!-- SSH Key 添加弹窗 -->
|
|
<n-modal
|
|
v-model:show="showAddSSHKeyDialog"
|
|
preset="card"
|
|
:title="t('Add New SSH Key')"
|
|
:style="modalStyle"
|
|
:mask-closable="true"
|
|
:close-on-esc="true"
|
|
>
|
|
<template #header>
|
|
<span class="text-lg font-semibold">{{ t('Add New SSH Key') }}</span>
|
|
</template>
|
|
<n-space vertical :size="20">
|
|
<p class="text-base text-gray-700">{{ t('Add a new SSH key to your account') }}</p>
|
|
<n-form-item :label="t('SSH Key')" :required="true">
|
|
<n-input
|
|
v-model:value="sshKeyValue"
|
|
type="textarea"
|
|
:placeholder="sshKeyPlaceholder"
|
|
:rows="6"
|
|
:size="inputSize"
|
|
class="w-full"
|
|
/>
|
|
</n-form-item>
|
|
<n-alert v-if="sshKeyError" type="error" :title="sshKeyError" />
|
|
</n-space>
|
|
<template #action>
|
|
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
|
|
<n-button @click="showAddSSHKeyDialog = false" :block="isMobile" :size="buttonSize">
|
|
{{ t('Cancel') }}
|
|
</n-button>
|
|
<n-button
|
|
type="primary"
|
|
:loading="addingSSHKey"
|
|
@click="handleAddSSHKey"
|
|
:block="isMobile"
|
|
:size="buttonSize"
|
|
>
|
|
{{ t('Add SSH Key') }}
|
|
</n-button>
|
|
</n-space>
|
|
</template>
|
|
</n-modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, h } from 'vue'
|
|
import {
|
|
NCard,
|
|
NSpace,
|
|
NButton,
|
|
NModal,
|
|
NFormItem,
|
|
NInput,
|
|
NAlert,
|
|
NIcon,
|
|
NDataTable,
|
|
NEmpty,
|
|
useMessage,
|
|
useDialog,
|
|
type DataTableColumns
|
|
} from 'naive-ui'
|
|
import { Icon } from '@iconify/vue'
|
|
import { t } from '../../shared/i18n'
|
|
import ClickToCopyField from '../../shared/components/ClickToCopyField.vue'
|
|
import {
|
|
getAccountInfo,
|
|
createApiSecret,
|
|
getUserSSHKeys,
|
|
addSSHKey,
|
|
deleteSSHKey,
|
|
setSSHKeyAsDefault
|
|
} from '../../shared/api/account'
|
|
|
|
const message = useMessage()
|
|
const dialog = useDialog()
|
|
|
|
const apiKey = ref('')
|
|
const apiSecretData = ref<{ api_key: string; api_secret: string } | null>(null)
|
|
const sshKeys = ref<any[]>([])
|
|
const sshKeyValue = ref('')
|
|
const sshKeyError = ref('')
|
|
|
|
const showCreateSecretDialog = ref(false)
|
|
const showAddSSHKeyDialog = ref(false)
|
|
const creatingSecret = ref(false)
|
|
const addingSSHKey = ref(false)
|
|
const loadingSSHKeys = ref(false)
|
|
|
|
const windowWidth = ref(window.innerWidth)
|
|
const isMobile = computed(() => windowWidth.value <= 768)
|
|
const modalStyle = computed(() => ({
|
|
width: isMobile.value ? '95vw' : '700px',
|
|
maxWidth: isMobile.value ? '95vw' : '90vw'
|
|
}))
|
|
const inputSize = computed(() => isMobile.value ? 'medium' : 'large')
|
|
const buttonSize = computed(() => isMobile.value ? 'medium' : 'medium')
|
|
|
|
const apiKeyButtonLabel = computed(() => {
|
|
return apiKey.value ? t('Regenerate API Key') : t('Create New API Key')
|
|
})
|
|
|
|
const sshKeyPlaceholder = computed(() => {
|
|
return t("Starts with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'")
|
|
})
|
|
|
|
const sshKeyColumns: DataTableColumns<any> = [
|
|
{
|
|
title: t('SSH Fingerprint'),
|
|
key: 'ssh_fingerprint',
|
|
render: (row) => {
|
|
return h('span', { class: 'font-mono' }, `SHA256:${row.ssh_fingerprint}`)
|
|
}
|
|
},
|
|
{
|
|
title: t('Added Time'),
|
|
key: 'creation',
|
|
width: 200,
|
|
render: (row) => {
|
|
return new Date(row.creation).toLocaleDateString()
|
|
}
|
|
},
|
|
{
|
|
title: t('Actions'),
|
|
key: 'actions',
|
|
width: 200,
|
|
render: (row) => {
|
|
return h('div', { class: 'flex gap-2' }, [
|
|
!row.is_default ? h(NButton, {
|
|
size: 'small',
|
|
onClick: () => handleSetDefault(row.name)
|
|
}, { default: () => t('Set as Default') }) : null,
|
|
h(NButton, {
|
|
size: 'small',
|
|
type: 'error',
|
|
onClick: () => handleDeleteSSHKey(row.name)
|
|
}, { default: () => t('Delete') })
|
|
])
|
|
}
|
|
}
|
|
]
|
|
|
|
const loadAccountInfo = async () => {
|
|
try {
|
|
const result = await getAccountInfo()
|
|
if (result.success && result.data) {
|
|
apiKey.value = result.data.user?.api_key || result.data.team?.user_info?.api_key || ''
|
|
}
|
|
} catch (error: any) {
|
|
console.error('加载账户信息失败:', error)
|
|
}
|
|
}
|
|
|
|
const loadSSHKeys = async () => {
|
|
loadingSSHKeys.value = true
|
|
try {
|
|
const result = await getUserSSHKeys()
|
|
if (result.success && result.data) {
|
|
sshKeys.value = result.data || []
|
|
}
|
|
} catch (error: any) {
|
|
console.error('加载 SSH 密钥失败:', error)
|
|
message.error(error.message || t('Failed to load SSH keys'))
|
|
} finally {
|
|
loadingSSHKeys.value = false
|
|
}
|
|
}
|
|
|
|
const handleCreateSecret = async () => {
|
|
creatingSecret.value = true
|
|
try {
|
|
const result = await createApiSecret()
|
|
if (result.success && result.data) {
|
|
apiSecretData.value = {
|
|
api_key: result.data.api_key,
|
|
api_secret: result.data.api_secret
|
|
}
|
|
apiKey.value = result.data.api_key
|
|
message.success(apiKey.value ? t('API key regenerated successfully') : t('API key created successfully'))
|
|
await loadAccountInfo()
|
|
} else {
|
|
message.error(result.message || t('Failed to create API key'))
|
|
}
|
|
} catch (error: any) {
|
|
message.error(error.message || t('Failed to create API key'))
|
|
} finally {
|
|
creatingSecret.value = false
|
|
}
|
|
}
|
|
|
|
const handleCloseCreateSecretDialog = () => {
|
|
showCreateSecretDialog.value = false
|
|
apiSecretData.value = null
|
|
}
|
|
|
|
const handleAddSSHKey = async () => {
|
|
if (!sshKeyValue.value) {
|
|
sshKeyError.value = t('SSH key is required')
|
|
return
|
|
}
|
|
|
|
// 验证 SSH 密钥格式
|
|
const sshKeyPatterns = [
|
|
/^ssh-rsa /,
|
|
/^ecdsa-sha2-nistp256 /,
|
|
/^ecdsa-sha2-nistp384 /,
|
|
/^ecdsa-sha2-nistp521 /,
|
|
/^ssh-ed25519 /,
|
|
/^sk-ecdsa-sha2-nistp256@openssh\.com /,
|
|
/^sk-ssh-ed25519@openssh\.com /
|
|
]
|
|
|
|
const isValid = sshKeyPatterns.some(pattern => pattern.test(sshKeyValue.value))
|
|
if (!isValid) {
|
|
sshKeyError.value = t('Invalid SSH key format')
|
|
return
|
|
}
|
|
|
|
sshKeyError.value = ''
|
|
addingSSHKey.value = true
|
|
|
|
try {
|
|
const result = await addSSHKey(sshKeyValue.value)
|
|
if (result.success) {
|
|
message.success(result.message || t('SSH key added successfully'))
|
|
showAddSSHKeyDialog.value = false
|
|
sshKeyValue.value = ''
|
|
await loadSSHKeys()
|
|
} else {
|
|
sshKeyError.value = result.message || t('Failed to add SSH key')
|
|
}
|
|
} catch (error: any) {
|
|
sshKeyError.value = error.message || t('Failed to add SSH key')
|
|
} finally {
|
|
addingSSHKey.value = false
|
|
}
|
|
}
|
|
|
|
const handleSetDefault = async (keyName: string) => {
|
|
try {
|
|
const result = await setSSHKeyAsDefault(keyName)
|
|
if (result.success) {
|
|
message.success(result.message || t('SSH key updated successfully'))
|
|
await loadSSHKeys()
|
|
} else {
|
|
message.error(result.message || t('Failed to set SSH key as default'))
|
|
}
|
|
} catch (error: any) {
|
|
message.error(error.message || t('Failed to set SSH key as default'))
|
|
}
|
|
}
|
|
|
|
const handleDeleteSSHKey = (keyName: string) => {
|
|
dialog.warning({
|
|
title: t('Delete SSH Key'),
|
|
content: t('Are you sure you want to delete this SSH key?'),
|
|
positiveText: t('Delete'),
|
|
negativeText: t('Cancel'),
|
|
positiveButtonProps: { type: 'error' },
|
|
onPositiveClick: async () => {
|
|
try {
|
|
const result = await deleteSSHKey(keyName)
|
|
if (result.success) {
|
|
message.success(result.message || t('SSH key deleted successfully'))
|
|
await loadSSHKeys()
|
|
} else {
|
|
message.error(result.message || t('Failed to delete SSH key'))
|
|
}
|
|
} catch (error: any) {
|
|
message.error(error.message || t('Failed to delete SSH key'))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadAccountInfo()
|
|
loadSSHKeys()
|
|
|
|
window.addEventListener('resize', () => {
|
|
windowWidth.value = window.innerWidth
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.developer-settings {
|
|
width: 100%;
|
|
}
|
|
|
|
.settings-card {
|
|
margin-bottom: 24px;
|
|
}
|
|
</style>
|