feat(audit): add localized security audit logs (#5462)
This commit is contained in:
+8
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user