feat(audit): add localized security audit logs (#5462)

This commit is contained in:
Calcium-Ion
2026-06-12 23:40:40 +08:00
committed by GitHub
parent 27b2b2c4b9
commit d0c4305a16
25 changed files with 1249 additions and 33 deletions
@@ -46,6 +46,7 @@ import {
hasAnyCacheTokens,
parseLogOther,
isViolationFeeLog,
renderAuditContent,
} from '../../lib/format'
import {
isDisplayableLogType,
@@ -100,6 +101,13 @@ function buildDetailSegments(
other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] {
// Audit (type=3) and login (type=7) logs: render localized content from the
// structured op descriptor instead of the raw (English-fallback) content.
if (log.type === 3 || log.type === 7) {
const text = renderAuditContent(other, t)
return text ? [{ text }] : []
}
if (log.type === 6) {
return [{ text: t('Async task refund') }]
}
@@ -29,6 +29,7 @@ import {
ShieldCheck,
UserCog,
Info,
LogIn,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
@@ -52,6 +53,7 @@ import {
isViolationFeeLog,
getFirstResponseTimeColor,
getResponseTimeColor,
renderAuditContent,
} from '../../lib/format'
import {
getLogTypeConfig,
@@ -60,6 +62,17 @@ import {
} from '../../lib/utils'
import type { LogOtherData } from '../../types'
// Maps a channel-update changed-field token (as recorded by the backend audit)
// to its i18n label key for display in the audit details.
const CHANNEL_FIELD_LABELS: Record<string, string> = {
status: 'Status',
models: 'Models',
group: 'Group',
type: 'Type',
base_url: 'Base URL',
key: 'Key',
}
function timingTextColorClass(
variant: 'success' | 'warning' | 'danger'
): string {
@@ -461,6 +474,41 @@ export function DetailsDialog(props: DetailsDialogProps) {
return `ID: ${id}`
})()
// Localized operation text rendered from the language-independent op
// descriptor (shared by audit type=3 and login type=7).
const operationText = renderAuditContent(other, t)
const auditRoute = isManage && props.isAdmin ? other?.audit_info : undefined
// Channel update records which fields changed (stable field tokens); render
// them with their localized labels for admins.
const changedFieldTokens =
isManage && props.isAdmin && Array.isArray(other?.op?.params?.changed_fields)
? (other.op.params.changed_fields as string[])
: []
const changedFieldsText = changedFieldTokens
.map((field) => t(CHANNEL_FIELD_LABELS[field] ?? field))
.join(', ')
const showManageAuditSection =
isManage && props.isAdmin && (operationText != null || auditRoute != null)
// Login audit (type=7); visible to the log owner, not admin-only.
const isLogin = props.log.type === 7
const loginAuditFields = isLogin
? ([
other?.login_method && {
label: t('Login Method'),
value: String(other.login_method),
},
props.log.ip && {
label: t('IP Address'),
value: props.log.ip,
},
other?.user_agent && {
label: t('User Agent'),
value: String(other.user_agent),
},
].filter(Boolean) as Array<{ label: string; value: string }>)
: []
const conversionChain =
other && Array.isArray(other.request_conversion)
? other.request_conversion.filter(Boolean)
@@ -749,6 +797,62 @@ export function DetailsDialog(props: DetailsDialogProps) {
/>
)}
{/* Operation audit info (type=3, admin only) */}
{showManageAuditSection && (
<DetailSection
icon={<ShieldCheck className='size-3.5' aria-hidden='true' />}
label={t('Operation Audit Info')}
>
{operationText != null && (
<DetailRow label={t('Operation')} value={operationText} />
)}
{changedFieldsText !== '' && (
<DetailRow
label={t('Changed Fields')}
value={changedFieldsText}
/>
)}
{auditRoute?.method && auditRoute?.route && (
<DetailRow
label={t('Request')}
value={`${auditRoute.method} ${auditRoute.route}`}
mono
/>
)}
{auditRoute?.status != null && (
<DetailRow
label={t('Result')}
value={
auditRoute.success
? `${t('Success')} (${auditRoute.status})`
: `${t('Failed')} (${auditRoute.status})`
}
mono
/>
)}
</DetailSection>
)}
{/* Login audit info (type=7) */}
{isLogin && loginAuditFields.length > 0 && (
<DetailSection
icon={<LogIn className='size-3.5' aria-hidden='true' />}
label={t('Login Info')}
>
{operationText != null && (
<DetailRow label={t('Operation')} value={operationText} />
)}
{loginAuditFields.map((field, idx) => (
<DetailRow
key={idx}
label={field.label}
value={field.value}
mono
/>
))}
</DetailSection>
)}
{/* Audio/WebSocket token breakdown */}
{hasAudioTokens && other && (
<DetailSection
+2
View File
@@ -58,6 +58,7 @@ export const LOG_TYPE_ENUM = {
SYSTEM: 4,
ERROR: 5,
REFUND: 6,
LOGIN: 7,
} as const
/**
@@ -95,6 +96,7 @@ export const LOG_TYPES = [
{ value: 4, label: 'System', color: 'purple' },
{ value: 5, label: 'Error', color: 'red' },
{ value: 6, label: 'Refund', color: 'blue' },
{ value: 7, label: 'Login', color: 'teal' },
] as const
/**
+106
View File
@@ -298,3 +298,109 @@ export function formatDuration(
return { durationSec, variant: durationSec > 60 ? 'red' : 'green' }
}
/**
* Maps a language-independent audit/login operation `action` to an i18n
* template string (the template itself is the i18n key, with {{placeholders}}).
*
* The backend stores only `action` + structured `params` in `other.op`; the UI
* renders localized content at display time so audit/login logs are fully
* translatable instead of being frozen to whatever language was written to DB.
*/
const AUDIT_TEMPLATES: Record<string, string> = {
login: 'Logged in successfully via {{method}}',
// User management
'user.create': 'Created user {{username}} (role {{role}})',
'user.update': 'Updated user {{username}} (ID: {{id}})',
'user.delete': 'Deleted user {{username}} (ID: {{id}})',
'user.manage': 'Performed {{action}} on user {{username}} (ID: {{id}})',
'user.quota_add': 'Increased user quota by {{quota}}',
'user.quota_subtract': 'Decreased user quota by {{quota}}',
'user.quota_override': 'Overrode user quota from {{from}} to {{to}}',
'user.binding_clear': 'Cleared {{bindingType}} binding for user {{username}}',
'user.2fa_disable': 'Force-disabled two-factor authentication for the user',
'user.passkey_register': 'Registered a passkey',
'user.passkey_delete': 'Deleted a passkey',
'user.topup_complete': 'Completed top-up order for the user',
'user.reset_passkey': 'Reset the user passkey',
'user.oauth_unbind': 'Removed an OAuth binding for the user',
// System settings
'option.update': 'Updated system setting {{key}}',
'option.payment_compliance': 'Confirmed payment compliance',
'option.reset_ratio': 'Reset model ratios',
'option.clear_affinity_cache': 'Cleared channel affinity cache',
// Custom OAuth
'custom_oauth.create': 'Created a custom OAuth provider',
'custom_oauth.update': 'Updated a custom OAuth provider',
'custom_oauth.delete': 'Deleted a custom OAuth provider',
// Performance / cache
'performance.clear_disk_cache': 'Cleared disk cache',
'performance.gc': 'Triggered garbage collection',
'performance.clear_logs': 'Cleared log files',
// Channel
'channel.create': 'Created channel {{name}} (type {{type}}, count {{count}})',
'channel.update': 'Updated channel {{name}} (ID: {{id}})',
'channel.delete': 'Deleted channel {{name}} (ID: {{id}})',
'channel.delete_batch': 'Batch deleted {{count}} channels',
'channel.delete_disabled': 'Deleted all disabled channels ({{count}})',
'channel.key_view': 'Viewed channel key {{name}} (ID: {{id}})',
'channel.tag_disable': 'Disabled channels with tag {{tag}}',
'channel.tag_enable': 'Enabled channels with tag {{tag}}',
'channel.tag_edit': 'Edited channels with tag {{tag}}',
'channel.tag_batch_set': 'Batch set tag for {{count}} channels',
'channel.copy':
'Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})',
'channel.multi_key_manage':
'Multi-key management {{action}} on channel (ID: {{id}})',
'channel.upstream_apply':
'Applied upstream model changes to channel (ID: {{id}})',
'channel.upstream_apply_all':
'Applied upstream model changes to {{count}} channels',
// Redemption codes
'redemption.create':
'Created {{count}} redemption codes named {{name}} ({{quota}} each)',
'redemption.update': 'Updated a redemption code',
'redemption.delete': 'Deleted a redemption code',
'redemption.delete_invalid': 'Deleted invalid redemption codes',
// Prefill groups
'prefill_group.create': 'Created a prefill group',
'prefill_group.update': 'Updated a prefill group',
'prefill_group.delete': 'Deleted a prefill group',
// Vendors
'vendor.create': 'Created a vendor',
'vendor.update': 'Updated a vendor',
'vendor.delete': 'Deleted a vendor',
// Model metadata
'model.create': 'Created a model',
'model.update': 'Updated a model',
'model.delete': 'Deleted a model',
'model.sync_upstream': 'Synced upstream models',
// Deployments
'deployment.create': 'Created a deployment',
'deployment.update': 'Updated a deployment',
'deployment.delete': 'Deleted a deployment',
// Subscriptions
'subscription.plan_create': 'Created a subscription plan',
'subscription.plan_update': 'Updated a subscription plan',
'subscription.bind': 'Bound a subscription',
// Logs
'log.clear': 'Cleared historical logs',
// Generic middleware fallback
generic: '{{method}} {{route}}',
}
/**
* Render the localized content of an audit/login log from its structured
* `other.op` descriptor. Returns null when the log has no recognized action,
* letting callers fall back to the raw `content` field.
*/
export function renderAuditContent(
other: LogOtherData | null | undefined,
t: (key: string, opts?: Record<string, unknown>) => string
): string | null {
const op = other?.op
if (!op?.action) return null
const template = AUDIT_TEMPLATES[op.action]
if (!template) return null
return t(template, (op.params ?? {}) as Record<string, unknown>)
}
+20 -1
View File
@@ -106,10 +106,29 @@ export interface LogOtherData {
server_ip?: string
version?: string
node_name?: string
// Manage audit fields (type=3, admin only)
// Operator identity for audit logs (type=3, admin only)
admin_username?: string
admin_id?: number | string
admin_role?: number
}
// Language-independent operation descriptor (audit/login logs).
// Frontend renders localized content from action + params via i18n templates.
op?: {
action?: string
params?: Record<string, string | number | boolean | string[]>
}
// Operation audit details written by the admin-audit fallback in authHelper (type=3, admin only)
audit_info?: {
method?: string
route?: string
path?: string
status?: number
success?: boolean
params?: Record<string, string>
}
// Login audit fields (type=7); visible to the log owner
login_method?: string
user_agent?: string
request_path?: string
request_conversion?: string[]
ws?: boolean