Files
oauth/resources/js/pages/admin/AuditLog.vue
2026-07-27 17:20:02 +02:00

212 lines
7.5 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import type { AuditLogEntry, Paginated } from '../../types/models';
const { t, locale } = useI18n();
const loading = ref(true);
const entries = ref<AuditLogEntry[]>([]);
const page = ref(1);
const lastPage = ref(1);
const total = ref(0);
const subjectType = ref<'' | 'user' | 'client'>('');
const action = ref('');
const actionOptions = [
'user.created',
'user.updated',
'user.deleted',
'user.restored',
'user.force_deleted',
'user.approved',
'user.app_role_assigned',
'user.app_role_revoked',
'client.created',
'client.revoked',
'user.2fa_enabled',
'user.2fa_disabled',
'user.2fa_recovery_regenerated',
'user.session_revoked',
'user.sessions_revoked_all',
'client.webhook_created',
'client.webhook_deleted',
'client.secret_rotated',
'user.password_reset',
];
const actionLabelKeys: Record<string, string> = {
'user.created': 'auditLog.actions.userCreated',
'user.updated': 'auditLog.actions.userUpdated',
'user.deleted': 'auditLog.actions.userDeleted',
'user.restored': 'auditLog.actions.userRestored',
'user.force_deleted': 'auditLog.actions.userForceDeleted',
'user.approved': 'auditLog.actions.userApproved',
'user.app_role_assigned': 'auditLog.actions.appRoleAssigned',
'user.app_role_revoked': 'auditLog.actions.appRoleRevoked',
'client.created': 'auditLog.actions.clientCreated',
'client.revoked': 'auditLog.actions.clientRevoked',
'user.2fa_enabled': 'auditLog.actions.twoFactorEnabled',
'user.2fa_disabled': 'auditLog.actions.twoFactorDisabled',
'user.2fa_recovery_regenerated': 'auditLog.actions.twoFactorRecoveryRegenerated',
'user.session_revoked': 'auditLog.actions.sessionRevoked',
'user.sessions_revoked_all': 'auditLog.actions.sessionsRevokedAll',
'client.webhook_created': 'auditLog.actions.webhookCreated',
'client.webhook_deleted': 'auditLog.actions.webhookDeleted',
'client.secret_rotated': 'auditLog.actions.secretRotated',
'user.password_reset': 'auditLog.actions.passwordReset',
};
function actionLabel(value: string): string {
const key = actionLabelKeys[value];
return key ? t(key) : value;
}
async function load(): Promise<void> {
loading.value = true;
const { data } = await axios.get<Paginated<AuditLogEntry>>('/web-api/admin/audit-log', {
params: {
page: page.value,
...(subjectType.value ? { subject_type: subjectType.value } : {}),
...(action.value ? { action: action.value } : {}),
},
});
entries.value = data.data;
lastPage.value = data.last_page;
total.value = data.total;
loading.value = false;
}
function goToPage(next: number): void {
if (next < 1 || next > lastPage.value) return;
page.value = next;
}
watch([subjectType, action], () => {
page.value = 1;
load();
});
watch(page, load);
onMounted(load);
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function formatMetadata(metadata: Record<string, unknown> | null): string {
if (!metadata || !Object.keys(metadata).length) return '—';
return Object.entries(metadata)
.map(([key, value]) => {
if (value && typeof value === 'object' && 'from' in value && 'to' in value) {
const { from, to } = value as { from: unknown; to: unknown };
return `${key}: ${from ?? '—'} → ${to ?? '—'}`;
}
if (value && typeof value === 'object' && 'changed' in value) {
return t('auditLog.fieldChanged', { field: key });
}
return `${key}: ${value}`;
})
.join(' · ');
}
</script>
<template>
<div class="audit-log">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('auditLog.title') }}</h1>
<p class="page-subtitle">{{ t('auditLog.subtitle') }}</p>
</div>
</div>
<div class="audit-log__filters">
<select v-model="subjectType" class="pill-select">
<option value="">{{ t('auditLog.allSubjects') }}</option>
<option value="user">{{ t('auditLog.subjectUser') }}</option>
<option value="client">{{ t('auditLog.subjectClient') }}</option>
</select>
<select v-model="action" class="pill-select">
<option value="">{{ t('auditLog.allActions') }}</option>
<option v-for="a in actionOptions" :key="a" :value="a">{{ actionLabel(a) }}</option>
</select>
</div>
<div class="table-card audit-log__table-card">
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
<p v-else-if="!entries.length" class="table__empty">{{ t('auditLog.empty') }}</p>
<table v-else class="table">
<thead class="table__head">
<tr>
<th class="table__head-cell">{{ t('auditLog.colWhen') }}</th>
<th class="table__head-cell">{{ t('auditLog.colActor') }}</th>
<th class="table__head-cell">{{ t('auditLog.colAction') }}</th>
<th class="table__head-cell">{{ t('auditLog.colSubject') }}</th>
<th class="table__head-cell">{{ t('auditLog.colDetails') }}</th>
<th class="table__head-cell">{{ t('auditLog.colIp') }}</th>
</tr>
</thead>
<tbody class="table__body">
<tr v-for="entry in entries" :key="entry.id">
<td class="table__cell audit-log__when">{{ formatDateTime(entry.created_at) }}</td>
<td class="table__cell">{{ entry.actor_name ?? t('auditLog.systemActor') }}</td>
<td class="table__cell table__cell--primary">{{ actionLabel(entry.action) }}</td>
<td class="table__cell">{{ entry.subject_label ?? entry.subject_id }}</td>
<td class="table__cell audit-log__details">{{ formatMetadata(entry.metadata) }}</td>
<td class="table__cell">{{ entry.ip_address }}</td>
</tr>
</tbody>
</table>
<div v-if="!loading && entries.length" class="audit-log__pager">
<button class="link" :disabled="page <= 1" @click="goToPage(page - 1)">{{ t('auditLog.prevPage') }}</button>
<span class="audit-log__pager-info">{{ t('auditLog.pageInfo', { page, lastPage, total }) }}</span>
<button class="link" :disabled="page >= lastPage" @click="goToPage(page + 1)">{{ t('auditLog.nextPage') }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.audit-log__filters {
margin-top: 1rem;
display: flex;
gap: 0.75rem;
}
.audit-log__table-card {
margin-top: 1rem;
}
.audit-log__when {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.audit-log__details {
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.audit-log__pager {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
font-size: 0.8125rem;
}
.audit-log__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.audit-log__pager-info {
color: var(--color-text-secondary);
}
</style>