Files
Starlight_Lancher/apps/app-frontend/src/components/ui/skin-site/admin/AdminAccessAuditPage.vue
T

274 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
skinSiteAccessAudit,
type SkinSiteAccessAuditEntry,
skinSiteUpdateAccessAuditSettings,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.audit.title', defaultMessage: 'Access audit' },
loading: { id: 'app.skin-site.admin.audit.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.audit.failed', defaultMessage: 'Could not load audit log.' },
empty: { id: 'app.skin-site.admin.audit.empty', defaultMessage: 'No audit entries.' },
subject: { id: 'app.skin-site.admin.audit.subject', defaultMessage: 'Subject' },
action: { id: 'app.skin-site.admin.audit.action', defaultMessage: 'Action' },
sourceIp: { id: 'app.skin-site.admin.audit.source-ip', defaultMessage: 'Source IP' },
reportedIp: {
id: 'app.skin-site.admin.audit.reported-ip',
defaultMessage: 'Reported player IP',
},
count: { id: 'app.skin-site.admin.audit.count', defaultMessage: 'Count' },
firstAt: { id: 'app.skin-site.admin.audit.first-at', defaultMessage: 'First seen' },
lastAt: { id: 'app.skin-site.admin.audit.last-at', defaultMessage: 'Last seen' },
proxy: { id: 'app.skin-site.admin.audit.proxy', defaultMessage: 'Proxy' },
internal: { id: 'app.skin-site.admin.audit.internal', defaultMessage: 'Internal' },
searchSubject: {
id: 'app.skin-site.admin.audit.search-subject',
defaultMessage: 'Subject',
},
searchIp: { id: 'app.skin-site.admin.audit.search-ip', defaultMessage: 'IP' },
searchAction: { id: 'app.skin-site.admin.audit.search-action', defaultMessage: 'Action' },
search: { id: 'app.skin-site.admin.audit.search', defaultMessage: 'Search' },
clear: { id: 'app.skin-site.admin.audit.clear', defaultMessage: 'Clear' },
loadMore: { id: 'app.skin-site.admin.audit.load-more', defaultMessage: 'Load more' },
retention: {
id: 'app.skin-site.admin.audit.retention',
defaultMessage: 'Retention (days)',
},
recordLimit: { id: 'app.skin-site.admin.audit.record-limit', defaultMessage: 'Record limit' },
saveRetention: {
id: 'app.skin-site.admin.audit.save-retention',
defaultMessage: 'Save',
},
retentionSaved: {
id: 'app.skin-site.admin.audit.retention-saved',
defaultMessage: 'Retention updated.',
},
})
const items = ref<SkinSiteAccessAuditEntry[]>([])
const nextCursor = ref<number | null>(null)
const retentionDays = ref(0)
const recordLimit = ref(0)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const subjectFilter = ref('')
const ipFilter = ref('')
const actionFilter = ref('')
const retentionDraft = ref(0)
const savingRetention = ref(false)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
nextCursor.value = null
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAccessAudit({
before: reset ? undefined : (nextCursor.value ?? undefined),
subject: subjectFilter.value.trim() || undefined,
ip: ipFilter.value.trim() || undefined,
action: actionFilter.value.trim() || undefined,
})
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
nextCursor.value = page.nextCursor
retentionDays.value = page.retentionDays
recordLimit.value = page.recordLimit
retentionDraft.value = page.retentionDays
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
function clearFilters() {
subjectFilter.value = ''
ipFilter.value = ''
actionFilter.value = ''
void load(true)
}
async function saveRetention() {
if (savingRetention.value) return
savingRetention.value = true
try {
const result = await skinSiteUpdateAccessAuditSettings(Number(retentionDraft.value) || 0)
if (!result.success) throw new Error(result.errorMessage)
retentionDays.value = result.payload.retentionDays
retentionDraft.value = result.payload.retentionDays
addNotification({ type: 'success', title: formatMessage(messages.retentionSaved) })
} catch (error) {
handleError(error)
} finally {
savingRetention.value = false
}
}
onMounted(() => void load(true))
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.recordLimit) }}: {{ recordLimit }}
</span>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchSubject) }}</span>
<input
v-model="subjectFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchIp) }}</span>
<input
v-model="ipFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchAction) }}</span>
<input
v-model="actionFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="load(true)"
>
{{ formatMessage(messages.search) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="clearFilters"
>
{{ formatMessage(messages.clear) }}
</button>
</div>
<div class="flex flex-wrap items-end gap-2 rounded-xl bg-bg-raised p-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.retention) }}</span>
<input
v-model.number="retentionDraft"
type="number"
min="1"
class="w-24 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="savingRetention"
@click="saveRetention"
>
{{ formatMessage(messages.saveRetention) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.retention) }}: {{ retentionDays }}
</span>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="entry in items"
:key="entry.id"
class="flex flex-col gap-1.5 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ entry.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ entry.subject }}
</span>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ entry.action }}
</span>
<span class="shrink-0 text-xs text-secondary">×{{ entry.count }}</span>
<span
v-if="entry.proxy"
class="shrink-0 rounded-full bg-orange px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.proxy) }}
</span>
<span
v-if="entry.internalServer"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.internal) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ formatMessage(messages.sourceIp) }}: {{ entry.sourceIp }}</span>
<span v-if="entry.reportedPlayerIp">
{{ formatMessage(messages.reportedIp) }}: {{ entry.reportedPlayerIp }}
</span>
<span>{{ formatMessage(messages.firstAt) }}: {{ formatDateTime(entry.firstAt) }}</span>
<span>{{ formatMessage(messages.lastAt) }}: {{ formatDateTime(entry.lastAt) }}</span>
</div>
</div>
<button
v-if="nextCursor !== null"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="loadingMore"
@click="load(false)"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
</div>
</template>