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:
shaw
2026-07-16 16:49:08 +08:00
parent 393a8fe56a
commit 35748d8c51
15 changed files with 953 additions and 269 deletions
@@ -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>
+467 -176
View File
@@ -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>