Harden audit privacy and admin modals
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 56s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m5s

This commit is contained in:
AzuTear
2026-06-26 20:47:07 +02:00
parent aeade132f9
commit 9393d433fa
17 changed files with 741 additions and 315 deletions
@@ -128,9 +128,10 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { onBeforeUnmount, onMounted } from 'vue'
import { Braces, ExternalLink, GitCompareArrows, ListTree, ShieldCheck, X } from '@lucide/vue'
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
import type { AuditLogRow } from './useAdminAuditManager'
const props = defineProps<{
@@ -145,18 +146,10 @@ function closeOnEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && props.entry) emit('close')
}
watch(
() => props.entry,
(entry) => {
if (typeof document !== 'undefined') {
document.body.style.overflow = entry ? 'hidden' : ''
}
},
)
useBodyScrollLock(() => Boolean(props.entry))
onMounted(() => window.addEventListener('keydown', closeOnEscape))
onBeforeUnmount(() => {
window.removeEventListener('keydown', closeOnEscape)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
@@ -46,6 +46,7 @@
import { X } from '@lucide/vue'
import { computed } from 'vue'
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
import type { FaqFormItem } from './adminContentTypes'
const props = defineProps<{
@@ -53,6 +54,8 @@ const props = defineProps<{
faq: FaqFormItem[]
}>()
useBodyScrollLock(() => props.open)
defineEmits<{
close: []
}>()
@@ -39,13 +39,17 @@
<script setup lang="ts">
import { X } from '@lucide/vue'
defineProps<{
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
const props = defineProps<{
open: boolean
title: string
url: string
contentHtml: string
}>()
useBodyScrollLock(() => props.open)
defineEmits<{
close: []
}>()
@@ -39,12 +39,16 @@
<script setup lang="ts">
import { X } from '@lucide/vue'
defineProps<{
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
const props = defineProps<{
open: boolean
contentHtml: string
updatedLabel: string
}>()
useBodyScrollLock(() => props.open)
defineEmits<{
close: []
}>()
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { onBeforeUnmount, onMounted } from 'vue'
import { Sparkles, X } from '@lucide/vue'
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
@@ -7,6 +7,7 @@ import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
import { useAdminReviewsManager } from './useAdminReviewsManager'
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
import { watchAdminToast } from '../../composables/useAdminToast'
const props = defineProps<{
@@ -55,20 +56,11 @@ function onKey(event: KeyboardEvent) {
}
}
watch(
() => props.open,
(open) => {
if (typeof document !== 'undefined') {
document.body.style.overflow = open ? 'hidden' : ''
}
},
{ immediate: true },
)
useBodyScrollLock(() => props.open)
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
import type { AdminAuditEntry, AdminAuditQueryOptions } from '../../types/awards'
const auditPageLimit = 80
@@ -294,9 +295,9 @@ function getDateBoundary(value: string, isEndOfDay: boolean) {
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
function buildRequestContextItems(entry: AdminAuditEntry) {
function buildRequestContextItems(entry: AdminAuditEntry, canViewAuditIp: boolean) {
return [
{ key: 'IP', value: entry.createdFromIp || 'nicht erfasst' },
...(canViewAuditIp ? [{ key: 'IP', value: entry.createdFromIp || 'nicht erfasst' }] : []),
{ key: 'User-Agent', value: entry.userAgent || 'nicht erfasst' },
]
}
@@ -312,7 +313,7 @@ function buildRelatedLink(entry: AdminAuditEntry) {
return route
}
function createAuditRow(entry: AdminAuditEntry): AuditLogRow {
function createAuditRow(entry: AdminAuditEntry, canViewAuditIp: boolean): AuditLogRow {
return {
...entry,
actionLabel: humanizeAction(entry.actionType),
@@ -324,13 +325,13 @@ function createAuditRow(entry: AdminAuditEntry): AuditLogRow {
ageLabel: formatAge(entry.createdAt),
metadataItems: parseMetadata(entry.metadataJson),
changeItems: parseChangeItems(entry.metadataJson),
requestContextItems: buildRequestContextItems(entry),
requestContextItems: buildRequestContextItems(entry, canViewAuditIp),
relatedLink: buildRelatedLink(entry),
rawMetadataJson: entry.metadataJson || '{}',
}
}
function downloadCsv(entries: AdminAuditEntry[]) {
function downloadCsv(entries: AdminAuditEntry[], canViewAuditIp: boolean) {
const rows = [
[
'Id',
@@ -340,7 +341,7 @@ function downloadCsv(entries: AdminAuditEntry[]) {
'Objekt-Id',
'Zusammenfassung',
'Zeitpunkt',
'IP',
...(canViewAuditIp ? ['IP'] : []),
'User-Agent',
'Metadaten',
],
@@ -352,7 +353,7 @@ function downloadCsv(entries: AdminAuditEntry[]) {
entry.entityId,
entry.summary,
new Date(entry.createdAt).toISOString(),
entry.createdFromIp,
...(canViewAuditIp ? [entry.createdFromIp ?? ''] : []),
entry.userAgent,
entry.metadataJson,
]),
@@ -372,6 +373,7 @@ function downloadCsv(entries: AdminAuditEntry[]) {
export function useAdminAuditManager() {
const store = useAwardsStore()
const authStore = useAuthStore()
const query = ref('')
const selectedAdmin = ref(allFilter)
const selectedAction = ref(allFilter)
@@ -405,9 +407,10 @@ export function useAdminAuditManager() {
label: entityOptions.find((option) => option.value === item.key)?.label ?? item.key,
})),
)
const auditRows = computed<AuditLogRow[]>(() => auditEntries.value.map(createAuditRow))
const canViewAuditIp = computed(() => authStore.isOwnerOrCreator)
const auditRows = computed<AuditLogRow[]>(() => auditEntries.value.map((entry) => createAuditRow(entry, canViewAuditIp.value)))
const metadataCount = computed(() => auditEntries.value.filter((entry) => parseMetadata(entry.metadataJson).length > 0).length)
const requestContextCount = computed(() => auditEntries.value.filter((entry) => entry.createdFromIp || entry.userAgent).length)
const requestContextCount = computed(() => auditEntries.value.filter((entry) => entry.userAgent || (canViewAuditIp.value && entry.createdFromIp)).length)
const recentDayCount = computed(() => {
const minTimestamp = Date.now() - 24 * 60 * 60 * 1000
return auditEntries.value.filter((entry) => new Date(entry.createdAt).getTime() >= minTimestamp).length
@@ -419,7 +422,7 @@ export function useAdminAuditManager() {
{ label: 'Geladen', value: loadedCountLabel.value, note: `Page ${auditPageLimit}` },
{ label: 'Treffer', value: totalCountLabel.value, note: 'serverseitig gefiltert' },
{ label: '24h', value: recentDayCount.value.toLocaleString('de-DE'), note: 'neue Aktionen' },
{ label: 'Kontext', value: requestContextCount.value.toLocaleString('de-DE'), note: 'mit IP oder User-Agent' },
{ label: 'Kontext', value: requestContextCount.value.toLocaleString('de-DE'), note: canViewAuditIp.value ? 'mit IP oder User-Agent' : 'mit User-Agent' },
])
const focusCards = computed<AuditFocusCard[]>(() => {
const topAdmin = adminCounts.value[0]
@@ -532,7 +535,7 @@ export function useAdminAuditManager() {
function exportAuditCsv() {
if (auditEntries.value.length === 0) return
downloadCsv(auditEntries.value)
downloadCsv(auditEntries.value, canViewAuditIp.value)
exportMessage.value = `CSV mit ${auditEntries.value.length.toLocaleString('de-DE')} geladenen Einträgen erstellt.`
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
exportMessageTimer = window.setTimeout(() => {