mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-02 08:31:35 +00:00
feat(security): gate admin role promotion behind step-up 2FA and harden admin TOTP verification
- 提升用户为管理员 / 创建管理员账号纳入敏感操作:handler 级 EnforceStepUp 门控 (admin API key 拒绝、未启用 TOTP 拒绝、无 grant 返回 STEP_UP_REQUIRED), 目标已是管理员的日常编辑不触发 - 管理员启用/停用 2FA 一律使用密码验证(默认通知邮箱常收不到验证码), verification-method 按用户角色返回;普通用户行为不变 - 用户编辑/创建弹窗接入 useStepUp:命中 STEP_UP_REQUIRED 弹 TOTP 验证并自动重试 - 审计日志清理入口与其他敏感操作对齐:未启用 2FA 时直接提示先启用 TOTP, 不再弹出无法完成的验证码输入框(后端强制现场 TOTP 语义不变) - 审计日志页重构:DataTable 布局、详情弹窗分区展示、时间范围改为 ops 同款 下拉(预设窗口 + 自定义起止支持时分)
This commit is contained in:
@@ -64,34 +64,57 @@
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- 创建管理员账号时后端要求 step-up 2FA,弹出 TOTP 验证后自动重试 -->
|
||||
<TotpStepUpDialog :controller="stepUp" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'
|
||||
import { useForm } from '@/composables/useForm'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
|
||||
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', role: 'user' as 'user' | 'admin', balance: '', concurrency: 1, rpm_limit: 0 })
|
||||
|
||||
const { loading, submit } = useForm({
|
||||
form,
|
||||
submitFn: async (data) => {
|
||||
const { balance: rawBalance, ...rest } = data
|
||||
const stepUp = useStepUp()
|
||||
const loading = ref(false)
|
||||
|
||||
const submit = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { balance: rawBalance, ...rest } = { ...form }
|
||||
const balance = String(rawBalance).trim()
|
||||
const payload: typeof rest & { balance?: number } = { ...rest }
|
||||
if (balance !== '') {
|
||||
payload.balance = Number(balance)
|
||||
}
|
||||
await adminAPI.users.create(payload)
|
||||
// 创建管理员属敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 验证并重试
|
||||
await stepUp.run(() => adminAPI.users.create(payload))
|
||||
appStore.showSuccess(t('admin.users.userCreated'))
|
||||
emit('success'); emit('close')
|
||||
},
|
||||
successMsg: t('admin.users.userCreated')
|
||||
})
|
||||
} catch (e: any) {
|
||||
if (isStepUpCancelled(e)) {
|
||||
// 用户主动取消二次验证:静默返回,表单保持打开。
|
||||
} else if (isStepUpBlocked(e)) {
|
||||
appStore.showError(
|
||||
stepUpBlockReason(e) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
|
||||
? t('stepUp.adminApiKeyForbidden')
|
||||
: t('stepUp.notEnabled')
|
||||
)
|
||||
} else {
|
||||
appStore.showError(e?.message || t('admin.users.failedToCreate'))
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', role: 'user', balance: '', concurrency: 1, rpm_limit: 0 }) })
|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- 角色提升为管理员时后端要求 step-up 2FA,弹出 TOTP 验证后自动重试 -->
|
||||
<TotpStepUpDialog :controller="stepUp" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -79,6 +82,8 @@ import type { AdminUser, UserAttributeValuesMap } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
|
||||
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
@@ -104,6 +109,8 @@ const copyPassword = async () => {
|
||||
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
|
||||
}
|
||||
}
|
||||
const stepUp = useStepUp()
|
||||
|
||||
const handleUpdateUser = async () => {
|
||||
if (!props.user) return
|
||||
if (!form.email.trim()) {
|
||||
@@ -114,16 +121,28 @@ const handleUpdateUser = async () => {
|
||||
appStore.showError(t('admin.users.concurrencyMin'))
|
||||
return
|
||||
}
|
||||
const userId = props.user.id
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: any = { email: form.email, username: form.username, notes: form.notes, role: form.role, concurrency: form.concurrency, rpm_limit: form.rpm_limit }
|
||||
if (form.password.trim()) data.password = form.password.trim()
|
||||
await adminAPI.users.update(props.user.id, data)
|
||||
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
|
||||
// 提升为管理员属敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 验证并重试
|
||||
await stepUp.run(() => adminAPI.users.update(userId, data))
|
||||
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(userId, form.customAttributes)
|
||||
appStore.showSuccess(t('admin.users.userUpdated'))
|
||||
emit('success'); emit('close')
|
||||
} catch (e: any) {
|
||||
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
|
||||
if (isStepUpCancelled(e)) {
|
||||
// 用户主动取消二次验证:静默返回,表单保持打开。
|
||||
} else if (isStepUpBlocked(e)) {
|
||||
appStore.showError(
|
||||
stepUpBlockReason(e) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
|
||||
? t('stepUp.adminApiKeyForbidden')
|
||||
: t('stepUp.notEnabled')
|
||||
)
|
||||
} else {
|
||||
appStore.showError(e?.message || t('admin.users.failedToUpdate'))
|
||||
}
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,150 +1,304 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page header -->
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('admin.audit.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.description') }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-danger btn-sm" @click="openClearDialog">
|
||||
{{ t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<!-- Filters -->
|
||||
<template #filters>
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<!-- Left: filter fields -->
|
||||
<div class="flex flex-1 flex-wrap items-end gap-4">
|
||||
<div class="w-full sm:w-auto sm:min-w-[240px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.q') }}</label>
|
||||
<div class="relative">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
v-model.trim="filters.q"
|
||||
type="text"
|
||||
class="input pl-10"
|
||||
:placeholder="t('admin.audit.filters.qPlaceholder')"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card p-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.q') }}</span>
|
||||
<input v-model.trim="filters.q" class="input" :placeholder="t('admin.audit.filters.qPlaceholder')" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.actorEmail') }}</span>
|
||||
<input v-model.trim="filters.actor_email" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.action') }}</span>
|
||||
<input v-model.trim="filters.action" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.clientIp') }}</span>
|
||||
<input v-model.trim="filters.client_ip" class="input" @keyup.enter="search" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.method') }}</span>
|
||||
<Select v-model="filters.method" :options="methodOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.authMethod') }}</span>
|
||||
<Select v-model="filters.auth_method" :options="authMethodOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.result') }}</span>
|
||||
<Select v-model="filters.success" :options="resultOptions" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.startTime') }}</span>
|
||||
<input v-model="filters.start_time" type="datetime-local" class="input" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.endTime') }}</span>
|
||||
<input v-model="filters.end_time" type="datetime-local" class="input" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="loading" @click="search">
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="loading" @click="resetFilters">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.actorEmail') }}</label>
|
||||
<input v-model.trim="filters.actor_email" type="text" class="input" @keyup.enter="search" />
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900/40">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.time') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.actor') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.action') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.method') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.result') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.clientIp') }}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.detail') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<tr v-if="loading">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="logs.length === 0">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('admin.audit.empty') }}</td>
|
||||
</tr>
|
||||
<tr v-for="log in logs" :key="log.id" class="hover:bg-gray-50 dark:hover:bg-dark-800/60">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{{ formatTime(log.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="font-medium">{{ log.actor_email || '—' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ log.actor_role }}<span v-if="log.auth_method"> · {{ log.auth_method }}</span></div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300">{{ log.action }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">{{ log.method }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span :class="statusBadgeClass(log.status_code)">{{ log.status_code }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">{{ log.client_ip || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<button type="button" class="text-primary-600 hover:underline dark:text-primary-400" @click="openDetail(log.id)">
|
||||
{{ t('admin.audit.columns.detail') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div class="w-full sm:w-auto sm:min-w-[180px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.action') }}</label>
|
||||
<input v-model.trim="filters.action" type="text" class="input" @keyup.enter="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[160px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.clientIp') }}</label>
|
||||
<input v-model.trim="filters.client_ip" type="text" class="input" @keyup.enter="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[140px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.method') }}</label>
|
||||
<Select v-model="filters.method" :options="methodOptions" @change="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[170px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.authMethod') }}</label>
|
||||
<Select v-model="filters.auth_method" :options="authMethodOptions" @change="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[140px]">
|
||||
<label class="input-label">{{ t('admin.audit.filters.result') }}</label>
|
||||
<Select v-model="filters.success" :options="resultOptions" @change="search" />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-auto sm:min-w-[170px]">
|
||||
<label class="input-label">{{ t('admin.dashboard.timeRange') }}</label>
|
||||
<Select
|
||||
:model-value="timeRange"
|
||||
:options="timeRangeOptions"
|
||||
@update:model-value="handleTimeRangeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" class="btn btn-primary" :disabled="loading" @click="search">
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" :disabled="loading" @click="resetFilters">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" @click="openClearDialog">
|
||||
<Icon name="trash" size="sm" class="mr-1.5" />
|
||||
{{ t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Table -->
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="logs" :loading="loading" row-key="id">
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="whitespace-nowrap text-gray-600 dark:text-gray-300">{{ formatTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actor="{ row }">
|
||||
<div class="min-w-0 max-w-[220px]">
|
||||
<div class="truncate font-medium text-gray-900 dark:text-white" :title="row.actor_email">
|
||||
{{ row.actor_email || '—' }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-gray-400">
|
||||
{{ row.actor_role }}<span v-if="row.auth_method"> · {{ authMethodLabel(row.auth_method) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-action="{ row }">
|
||||
<div class="min-w-0 max-w-xs">
|
||||
<div class="truncate font-mono text-sm text-gray-800 dark:text-gray-200" :title="row.action">
|
||||
{{ row.action }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate font-mono text-xs text-gray-400" :title="`${row.method} ${row.path}`">
|
||||
{{ row.method }} {{ row.path }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status_code="{ row }">
|
||||
<span :class="statusBadgeClass(row.status_code)">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(row.status_code)"></span>
|
||||
{{ row.status_code }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-latency_ms="{ value }">
|
||||
<span class="whitespace-nowrap text-gray-500 dark:text-gray-400">{{ value }} ms</span>
|
||||
</template>
|
||||
|
||||
<template #cell-client_ip="{ value }">
|
||||
<span class="whitespace-nowrap font-mono text-gray-600 dark:text-gray-300">{{ value || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@click="openDetail(row.id)"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
{{ t('admin.audit.columns.detail') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center py-8">
|
||||
<Icon name="shield" size="xl" class="mb-4 h-12 w-12 text-gray-300 dark:text-dark-600" />
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('admin.audit.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="total > 0"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@update:page="onPageChange"
|
||||
@update:pageSize="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<BaseDialog :show="detailVisible" :title="t('admin.audit.detail.title')" width="wide" @close="detailVisible = false">
|
||||
<div v-if="detailLoading" class="py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="detail" class="space-y-3 text-sm">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<DetailRow :label="t('admin.audit.columns.time')" :value="formatTime(detail.created_at)" />
|
||||
<DetailRow :label="t('admin.audit.columns.actor')" :value="detail.actor_email || '—'" />
|
||||
<DetailRow :label="t('admin.audit.detail.actorRole')" :value="detail.actor_role" />
|
||||
<DetailRow :label="t('admin.audit.filters.authMethod')" :value="detail.auth_method" />
|
||||
<DetailRow :label="t('admin.audit.columns.action')" :value="detail.action" mono />
|
||||
<DetailRow :label="t('admin.audit.detail.methodPath')" :value="`${detail.method} ${detail.path}`" mono />
|
||||
<DetailRow :label="t('admin.audit.columns.result')" :value="String(detail.status_code)" />
|
||||
<DetailRow :label="t('admin.audit.detail.latency')" :value="`${detail.latency_ms} ms`" />
|
||||
<DetailRow :label="t('admin.audit.columns.clientIp')" :value="detail.client_ip || '—'" />
|
||||
<DetailRow :label="t('admin.audit.detail.requestId')" :value="detail.request_id || '—'" mono />
|
||||
<DetailRow :label="t('admin.audit.detail.credential')" :value="detail.credential_masked || '—'" mono />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.userAgent') }}</div>
|
||||
<div class="break-all rounded bg-gray-50 p-2 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ detail.user_agent || '—' }}</div>
|
||||
</div>
|
||||
<div v-if="detail.request_body">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.requestBody') }}</div>
|
||||
<pre class="max-h-72 overflow-auto rounded bg-gray-50 p-3 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ prettyBody(detail.request_body) }}</pre>
|
||||
</div>
|
||||
<div v-if="detail.extra && Object.keys(detail.extra).length">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.extra') }}</div>
|
||||
<pre class="max-h-48 overflow-auto rounded bg-gray-50 p-3 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ JSON.stringify(detail.extra, null, 2) }}</pre>
|
||||
<BaseDialog
|
||||
:show="detailVisible"
|
||||
:title="t('admin.audit.detail.title')"
|
||||
width="wide"
|
||||
:close-on-click-outside="true"
|
||||
@close="detailVisible = false"
|
||||
>
|
||||
<div v-if="detailLoading" class="flex items-center justify-center py-16">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail" class="space-y-5 py-2">
|
||||
<!-- Hero: action + result at a glance -->
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/60 p-5 dark:border-dark-700 dark:bg-dark-900/60">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span :class="statusBadgeClass(detail.status_code)">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(detail.status_code)"></span>
|
||||
{{ detail.status_code }} {{ statusText(detail.status_code) }}
|
||||
</span>
|
||||
<span class="break-all font-mono text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ detail.action }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2 rounded-lg bg-white px-3 py-2 ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-600">
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[11px] font-bold text-gray-700 dark:bg-dark-700 dark:text-gray-200">
|
||||
{{ detail.method }}
|
||||
</span>
|
||||
<span class="break-all font-mono text-xs text-gray-600 dark:text-gray-300">{{ detail.path }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Icon name="clock" size="xs" />
|
||||
{{ formatTime(detail.created_at) }}
|
||||
</span>
|
||||
<span>{{ t('admin.audit.detail.latency') }} {{ detail.latency_ms }} ms</span>
|
||||
<span v-if="detail.request_id" class="inline-flex items-center gap-1">
|
||||
{{ t('admin.audit.detail.requestId') }}
|
||||
<span class="break-all font-mono">{{ detail.request_id }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actor / auth / source -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.columns.actor') }}
|
||||
</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.actor_email || '—' }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-gray-400">{{ detail.actor_role }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.filters.authMethod') }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ authMethodLabel(detail.auth_method) || '—' }}
|
||||
</div>
|
||||
<div v-if="detail.credential_masked" class="mt-0.5 break-all font-mono text-xs text-gray-400">
|
||||
{{ detail.credential_masked }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.columns.clientIp') }}
|
||||
</div>
|
||||
<div class="mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.client_ip || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User-Agent -->
|
||||
<section>
|
||||
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.detail.userAgent') }}
|
||||
</h4>
|
||||
<div class="break-all rounded-xl bg-gray-50 p-3 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">
|
||||
{{ detail.user_agent || '—' }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Request body (redacted) -->
|
||||
<section v-if="detail.request_body">
|
||||
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.detail.requestBody') }}
|
||||
</h4>
|
||||
<pre class="max-h-72 overflow-auto rounded-xl bg-gray-50 p-4 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">{{ prettyBody(detail.request_body) }}</pre>
|
||||
</section>
|
||||
|
||||
<!-- Extra -->
|
||||
<section v-if="detail.extra && Object.keys(detail.extra).length">
|
||||
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
{{ t('admin.audit.detail.extra') }}
|
||||
</h4>
|
||||
<pre class="max-h-48 overflow-auto rounded-xl bg-gray-50 p-4 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">{{ JSON.stringify(detail.extra, null, 2) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Custom time range dialog (与 /admin/ops 时间下拉一致的自定义范围,支持时分) -->
|
||||
<BaseDialog
|
||||
:show="showCustomTimeRangeDialog"
|
||||
:title="t('admin.ops.timeRange.custom')"
|
||||
width="narrow"
|
||||
@close="handleCustomTimeRangeCancel"
|
||||
>
|
||||
<div class="space-y-4 py-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.customTimeRange.startTime') }}</label>
|
||||
<input v-model="customStartTimeInput" type="datetime-local" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.customTimeRange.endTime') }}</label>
|
||||
<input v-model="customEndTimeInput" type="datetime-local" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button type="button" class="btn btn-secondary" @click="handleCustomTimeRangeCancel">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!customStartTimeInput || !customEndTimeInput"
|
||||
@click="handleCustomTimeRangeConfirm"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Clear confirmation → step-up TOTP -->
|
||||
@@ -159,55 +313,63 @@
|
||||
@cancel="clearConfirmVisible = false"
|
||||
/>
|
||||
|
||||
<!-- Reused: TOTP prompt for the clear operation -->
|
||||
<div v-if="clearTotpVisible" class="fixed inset-0 z-[60] overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50" @click="cancelClearTotp"></div>
|
||||
<div class="relative w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800">
|
||||
<h3 class="mb-2 text-center text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.audit.clearConfirm.totpTitle') }}</h3>
|
||||
<p class="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.clearConfirm.totpHint') }}</p>
|
||||
<input
|
||||
v-model.trim="clearTotpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
class="input mb-4 text-center text-lg tracking-[0.5em]"
|
||||
placeholder="••••••"
|
||||
@keyup.enter="submitClear"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-secondary flex-1" :disabled="clearing" @click="cancelClearTotp">{{ t('common.cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger flex-1" :disabled="clearing || clearTotpCode.length !== 6" @click="submitClear">
|
||||
{{ clearing ? t('common.loading') : t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TOTP prompt for the clear operation -->
|
||||
<BaseDialog
|
||||
:show="clearTotpVisible"
|
||||
:title="t('admin.audit.clearConfirm.totpTitle')"
|
||||
width="narrow"
|
||||
:z-index="60"
|
||||
@close="cancelClearTotp"
|
||||
>
|
||||
<div class="py-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.clearConfirm.totpHint') }}</p>
|
||||
<input
|
||||
v-model.trim="clearTotpCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
class="input mt-4 text-center text-lg tracking-[0.5em]"
|
||||
placeholder="••••••"
|
||||
@keyup.enter="submitClear"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button type="button" class="btn btn-secondary" :disabled="clearing" @click="cancelClearTotp">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
:disabled="clearing || clearTotpCode.length !== 6"
|
||||
@click="submitClear"
|
||||
>
|
||||
{{ clearing ? t('common.loading') : t('admin.audit.clearAll') }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI, type AuditLog } from '@/api/admin'
|
||||
import { totpAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Tiny inline label/value row for the detail dialog.
|
||||
const DetailRow = (props: { label: string; value: string; mono?: boolean }) =>
|
||||
h('div', { class: 'flex flex-col gap-0.5' }, [
|
||||
h('span', { class: 'text-xs text-gray-400' }, props.label),
|
||||
h('span', { class: ['text-gray-700 dark:text-gray-200 break-all', props.mono ? 'font-mono text-xs' : ''] }, props.value)
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
@@ -221,11 +383,96 @@ const filters = reactive({
|
||||
client_ip: '',
|
||||
method: '',
|
||||
auth_method: '',
|
||||
success: '',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
success: ''
|
||||
})
|
||||
|
||||
// 时间范围:预设窗口(同 /admin/ops 时间下拉)+ 自定义起止(datetime-local,支持时分)
|
||||
const timeRange = ref('')
|
||||
const customStartTime = ref('')
|
||||
const customEndTime = ref('')
|
||||
const showCustomTimeRangeDialog = ref(false)
|
||||
const customStartTimeInput = ref('')
|
||||
const customEndTimeInput = ref('')
|
||||
|
||||
const TIME_RANGE_MINUTES: Record<string, number> = {
|
||||
'30m': 30,
|
||||
'1h': 60,
|
||||
'6h': 6 * 60,
|
||||
'24h': 24 * 60,
|
||||
'7d': 7 * 24 * 60,
|
||||
'30d': 30 * 24 * 60
|
||||
}
|
||||
|
||||
const timeRangeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.audit.filters.all') },
|
||||
{ value: '30m', label: t('admin.ops.timeRange.30m') },
|
||||
{ value: '1h', label: t('admin.ops.timeRange.1h') },
|
||||
{ value: '6h', label: t('admin.ops.timeRange.6h') },
|
||||
{ value: '24h', label: t('admin.ops.timeRange.24h') },
|
||||
{ value: '7d', label: t('admin.ops.timeRange.7d') },
|
||||
{ value: '30d', label: t('admin.ops.timeRange.30d') },
|
||||
{
|
||||
value: 'custom',
|
||||
label:
|
||||
timeRange.value === 'custom' && customStartTime.value && customEndTime.value
|
||||
? `${t('admin.ops.timeRange.custom')} (${formatCustomTimeRangeLabel(customStartTime.value, customEndTime.value)})`
|
||||
: t('admin.ops.timeRange.custom')
|
||||
}
|
||||
])
|
||||
|
||||
function formatCustomTimeRangeLabel(startTime: string, endTime: string): string {
|
||||
const fmt = (raw: string) => {
|
||||
const d = new Date(raw)
|
||||
if (Number.isNaN(d.getTime())) return raw
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
return `${fmt(startTime)} ~ ${fmt(endTime)}`
|
||||
}
|
||||
|
||||
function toDatetimeLocal(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function handleTimeRangeChange(val: string | number | boolean | null) {
|
||||
const value = String(val ?? '')
|
||||
if (value === 'custom') {
|
||||
// 预填:已有自定义值沿用,否则默认最近1小时(本地时区)
|
||||
const now = new Date()
|
||||
customStartTimeInput.value = customStartTime.value || toDatetimeLocal(new Date(now.getTime() - 60 * 60 * 1000))
|
||||
customEndTimeInput.value = customEndTime.value || toDatetimeLocal(now)
|
||||
showCustomTimeRangeDialog.value = true
|
||||
return
|
||||
}
|
||||
timeRange.value = value
|
||||
search()
|
||||
}
|
||||
|
||||
function handleCustomTimeRangeConfirm() {
|
||||
if (!customStartTimeInput.value || !customEndTimeInput.value) return
|
||||
customStartTime.value = customStartTimeInput.value
|
||||
customEndTime.value = customEndTimeInput.value
|
||||
timeRange.value = 'custom'
|
||||
showCustomTimeRangeDialog.value = false
|
||||
search()
|
||||
}
|
||||
|
||||
function handleCustomTimeRangeCancel() {
|
||||
// 未确认不改变当前时间范围;Select 是受控组件,展示值保持不变。
|
||||
showCustomTimeRangeDialog.value = false
|
||||
}
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'created_at', label: t('admin.audit.columns.time') },
|
||||
{ key: 'actor', label: t('admin.audit.columns.actor') },
|
||||
{ key: 'action', label: t('admin.audit.columns.action') },
|
||||
{ key: 'status_code', label: t('admin.audit.columns.result') },
|
||||
{ key: 'latency_ms', label: t('admin.audit.detail.latency') },
|
||||
{ key: 'client_ip', label: t('admin.audit.columns.clientIp') },
|
||||
{ key: 'actions', label: t('common.actions') }
|
||||
])
|
||||
|
||||
const methodOptions = computed(() => [
|
||||
{ value: '', label: t('admin.audit.filters.all') },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
@@ -247,6 +494,11 @@ const resultOptions = computed(() => [
|
||||
{ value: 'false', label: t('admin.audit.filters.resultFailure') }
|
||||
])
|
||||
|
||||
function authMethodLabel(method: string): string {
|
||||
const found = authMethodOptions.value.find((o) => o.value === method)
|
||||
return found && found.value ? found.label : method
|
||||
}
|
||||
|
||||
function toRFC3339(local: string): string | undefined {
|
||||
if (!local) return undefined
|
||||
const d = new Date(local)
|
||||
@@ -254,6 +506,18 @@ function toRFC3339(local: string): string | undefined {
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function buildTimeRangeQuery(): { start_time?: string; end_time?: string } {
|
||||
if (timeRange.value === 'custom') {
|
||||
return {
|
||||
start_time: toRFC3339(customStartTime.value),
|
||||
end_time: toRFC3339(customEndTime.value)
|
||||
}
|
||||
}
|
||||
const minutes = TIME_RANGE_MINUTES[timeRange.value]
|
||||
if (!minutes) return {}
|
||||
return { start_time: new Date(Date.now() - minutes * 60 * 1000).toISOString() }
|
||||
}
|
||||
|
||||
function buildQuery() {
|
||||
return {
|
||||
page: page.value,
|
||||
@@ -265,8 +529,7 @@ function buildQuery() {
|
||||
method: filters.method || undefined,
|
||||
auth_method: filters.auth_method || undefined,
|
||||
success: filters.success || undefined,
|
||||
start_time: toRFC3339(filters.start_time),
|
||||
end_time: toRFC3339(filters.end_time)
|
||||
...buildTimeRangeQuery()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,8 +559,9 @@ function resetFilters() {
|
||||
filters.method = ''
|
||||
filters.auth_method = ''
|
||||
filters.success = ''
|
||||
filters.start_time = ''
|
||||
filters.end_time = ''
|
||||
timeRange.value = ''
|
||||
customStartTime.value = ''
|
||||
customEndTime.value = ''
|
||||
search()
|
||||
}
|
||||
|
||||
@@ -344,8 +608,25 @@ const clearConfirmVisible = ref(false)
|
||||
const clearTotpVisible = ref(false)
|
||||
const clearTotpCode = ref('')
|
||||
const clearing = ref(false)
|
||||
const checkingTotpStatus = ref(false)
|
||||
|
||||
function openClearDialog() {
|
||||
// 与其他敏感操作一致:未启用 2FA 时直接提示去个人资料启用 TOTP,
|
||||
// 而不是弹出一个无法完成的验证码输入框(后端会以 TOTP_NOT_SETUP 拒绝)。
|
||||
async function openClearDialog() {
|
||||
if (checkingTotpStatus.value) return
|
||||
checkingTotpStatus.value = true
|
||||
try {
|
||||
const status = await totpAPI.getStatus()
|
||||
if (!status.enabled) {
|
||||
appStore.showError(t('stepUp.notEnabled'))
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('admin.audit.loadFailed'))
|
||||
return
|
||||
} finally {
|
||||
checkingTotpStatus.value = false
|
||||
}
|
||||
clearConfirmVisible.value = true
|
||||
}
|
||||
|
||||
@@ -383,12 +664,22 @@ function formatTime(iso: string): string {
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
function statusText(status: number): string {
|
||||
return status < 400 ? t('admin.audit.filters.resultSuccess') : t('admin.audit.filters.resultFailure')
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: number): string {
|
||||
const base = 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium '
|
||||
const base = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold '
|
||||
if (status >= 500) return base + 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
if (status >= 400) return base + 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
return base + 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
}
|
||||
|
||||
function statusDotClass(status: number): string {
|
||||
if (status >= 500) return 'bg-red-500'
|
||||
if (status >= 400) return 'bg-amber-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
onMounted(fetchLogs)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user