diff --git a/Backend/Contracts/AdminModerationContracts.cs b/Backend/Contracts/AdminModerationContracts.cs index fcfbc16..9d0953a 100644 --- a/Backend/Contracts/AdminModerationContracts.cs +++ b/Backend/Contracts/AdminModerationContracts.cs @@ -43,7 +43,7 @@ public sealed record AdminAuditEntryDto( string Summary, DateTimeOffset CreatedAt, string MetadataJson, - string CreatedFromIp, + string? CreatedFromIp, string UserAgent); public sealed record AdminAuditEntriesResponse( diff --git a/Backend/Endpoints/AdminDashboardEndpoints.cs b/Backend/Endpoints/AdminDashboardEndpoints.cs index ad1d40f..6b0308e 100644 --- a/Backend/Endpoints/AdminDashboardEndpoints.cs +++ b/Backend/Endpoints/AdminDashboardEndpoints.cs @@ -20,8 +20,9 @@ public static class AdminDashboardEndpoints return group; } - private static async Task GetDashboard(AwardsDbContext db) + private static async Task GetDashboard(AwardsDbContext db, HttpContext context) { + var canViewAuditIp = CanViewAuditIp(context); var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent); if (currentSeason is null) { @@ -68,7 +69,7 @@ public static class AdminDashboardEndpoints item.Summary, item.CreatedAt, item.MetadataJson, - item.CreatedFromIp, + canViewAuditIp ? item.CreatedFromIp : null, item.UserAgent)) .ToArrayAsync(); @@ -101,10 +102,12 @@ public static class AdminDashboardEndpoints DateTimeOffset? from, DateTimeOffset? to, string? cursor, - AwardsDbContext db) + AwardsDbContext db, + HttpContext context) { var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500); var search = query?.Trim(); + var canViewAuditIp = CanViewAuditIp(context); var auditQuery = db.AdminAuditEntries.AsNoTracking(); if (!TryDecodeAuditCursor(cursor, out var decodedCursor)) @@ -122,8 +125,8 @@ public static class AdminDashboardEndpoints EF.Functions.ILike(item.EntityId, pattern) || EF.Functions.ILike(item.Summary, pattern) || EF.Functions.ILike(item.MetadataJson, pattern) || - EF.Functions.ILike(item.CreatedFromIp, pattern) || - EF.Functions.ILike(item.UserAgent, pattern)); + EF.Functions.ILike(item.UserAgent, pattern) || + (canViewAuditIp && EF.Functions.ILike(item.CreatedFromIp, pattern))); } if (!string.IsNullOrWhiteSpace(admin)) @@ -176,7 +179,7 @@ public static class AdminDashboardEndpoints item.Summary, item.CreatedAt, item.MetadataJson, - item.CreatedFromIp, + canViewAuditIp ? item.CreatedFromIp : null, item.UserAgent)) .ToArrayAsync(); @@ -197,6 +200,9 @@ public static class AdminDashboardEndpoints private static string EncodeAuditCursor(AdminAuditEntryDto entry) => $"{entry.CreatedAt.UtcTicks}:{entry.Id}"; + private static bool CanViewAuditIp(HttpContext context) => + AdminRoles.IsPrivilegedFullControlRole(context.GetCurrentSession()?.Role); + private static bool TryDecodeAuditCursor(string? cursor, out AuditCursor? decodedCursor) { decodedCursor = null; diff --git a/frontend/src/components/AppShellAccountModals.vue b/frontend/src/components/AppShellAccountModals.vue index 54ee15e..7a56c6a 100644 --- a/frontend/src/components/AppShellAccountModals.vue +++ b/frontend/src/components/AppShellAccountModals.vue @@ -1,5 +1,6 @@ diff --git a/frontend/src/components/admin/AdminAuditDetailDrawer.vue b/frontend/src/components/admin/AdminAuditDetailDrawer.vue index d56527e..5922890 100644 --- a/frontend/src/components/admin/AdminAuditDetailDrawer.vue +++ b/frontend/src/components/admin/AdminAuditDetailDrawer.vue @@ -128,9 +128,10 @@ diff --git a/frontend/src/components/admin/AdminContentFaqPreviewModal.vue b/frontend/src/components/admin/AdminContentFaqPreviewModal.vue index 6f4c1d8..087611c 100644 --- a/frontend/src/components/admin/AdminContentFaqPreviewModal.vue +++ b/frontend/src/components/admin/AdminContentFaqPreviewModal.vue @@ -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: [] }>() diff --git a/frontend/src/components/admin/AdminContentFooterPreviewModal.vue b/frontend/src/components/admin/AdminContentFooterPreviewModal.vue index 52b727a..43a4f3c 100644 --- a/frontend/src/components/admin/AdminContentFooterPreviewModal.vue +++ b/frontend/src/components/admin/AdminContentFooterPreviewModal.vue @@ -39,13 +39,17 @@ diff --git a/frontend/src/components/admin/useAdminAuditManager.ts b/frontend/src/components/admin/useAdminAuditManager.ts index 68dadbb..6d84e65 100644 --- a/frontend/src/components/admin/useAdminAuditManager.ts +++ b/frontend/src/components/admin/useAdminAuditManager.ts @@ -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(() => auditEntries.value.map(createAuditRow)) + const canViewAuditIp = computed(() => authStore.isOwnerOrCreator) + const auditRows = computed(() => 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(() => { 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(() => { diff --git a/frontend/src/components/home/HomeAccountAndPrivacyModals.vue b/frontend/src/components/home/HomeAccountAndPrivacyModals.vue index f6968f5..ed1a716 100644 --- a/frontend/src/components/home/HomeAccountAndPrivacyModals.vue +++ b/frontend/src/components/home/HomeAccountAndPrivacyModals.vue @@ -1,5 +1,6 @@ diff --git a/frontend/src/views/admin/AdminTeamView.vue b/frontend/src/views/admin/AdminTeamView.vue index 6643061..68b1707 100644 --- a/frontend/src/views/admin/AdminTeamView.vue +++ b/frontend/src/views/admin/AdminTeamView.vue @@ -4,6 +4,7 @@ import { computed, reactive, ref } from 'vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager' +import { useBodyScrollLock } from '../../composables/useBodyScrollLock' import { watchAdminToast } from '../../composables/useAdminToast' import Button from '../../components/ui/Button.vue' import Card from '../../components/ui/Card.vue' @@ -67,6 +68,15 @@ const memberPendingDeletion = computed(() => members.value.find((member) => member.id === confirmDeleteMemberId.value) ?? null, ) const onlineMembers = computed(() => members.value.filter((member) => member.isOnline).length) +const anyTeamModalOpen = computed(() => + passwordModalOpen.value + || createMemberModalOpen.value + || editMemberModalOpen.value + || rolePermissionsModalOpen.value + || Boolean(memberPendingDeletion.value), +) + +useBodyScrollLock(() => anyTeamModalOpen.value) const summaryItems = computed(() => [ { label: 'Mitglieder', value: members.value.length, note: `${onlineMembers.value} online` },