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
+172 -4
View File
@@ -22,6 +22,7 @@ import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModa
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import {
createSeasonTimelineRows,
hasShowDayPassed,
normalizePhaseKey,
resolveAutoPhase,
SEASON_PHASES,
@@ -32,6 +33,7 @@ import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonMana
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
const {
store,
@@ -88,10 +90,20 @@ const currentPhaseKey = computed(() => normalizePhaseKey(form.currentPhase))
const autoPhase = computed(() => resolveAutoPhase(form))
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
const todayLabel = computed(() =>
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
)
const editingPhase = ref<PhaseKey | null>(null)
const timelineError = ref('')
const draft = reactive({ start: '', end: '', showStartsAt: '20:00' })
const phaseWarning = ref<{
action: 'activate' | 'complete'
phase: string
reason: string
detail: string
range: string
} | null>(null)
const timelineRows = computed(() =>
createSeasonTimelineRows(form, currentPhaseKey.value, autoPhase.value ?? '', editingPhase.value).map((row) => ({
@@ -150,6 +162,118 @@ function phaseCountdown(row: (typeof timelineRows.value)[number]): string | null
return `Startet in ${days} Tagen`
}
function parseLocalDate(value: string | undefined | null) {
if (!value) return null
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
const date = new Date(year, month - 1, day)
date.setHours(0, 0, 0, 0)
return Number.isNaN(date.getTime()) ? null : date
}
function formatLocalDate(value: string | undefined | null) {
const date = parseLocalDate(value)
return date
? date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: 'offen'
}
function todayAtStartOfDay() {
const today = new Date()
today.setHours(0, 0, 0, 0)
return today
}
function phaseWindowWarning(row: (typeof timelineRows.value)[number]) {
if (row.key === 'completed' || !row.start || !row.end) return null
const start = parseLocalDate(form[row.start])
const end = parseLocalDate(form[row.end])
if (!start || !end) {
return {
reason: `Für „${row.title}“ ist kein vollständiges Zeitfenster hinterlegt.`,
detail: 'Bitte prüfe zuerst Start und Ende der Phase oder bestätige bewusst die manuelle Aktivierung.',
}
}
const today = todayAtStartOfDay()
if (today < start) {
return {
reason: `${row.title}“ startet laut Zeitplan erst am ${formatLocalDate(form[row.start])}.`,
detail: 'Wenn du jetzt aktivierst, öffnen Public-API, Countdown und Teilnahme-Gates vor dem geplanten Start.',
}
}
if (today > end) {
return {
reason: `${row.title}“ ist laut Zeitplan seit dem ${formatLocalDate(form[row.end])} beendet.`,
detail: 'Wenn du jetzt aktivierst, springt die öffentliche Award-Phase zurück in ein bereits abgelaufenes Fenster.',
}
}
return null
}
function requestPhaseActivation(row: (typeof timelineRows.value)[number]) {
if (saving.value || !selectedSeasonId.value || row.active || row.finalLocked) return
const warning = phaseWindowWarning(row)
if (warning) {
phaseWarning.value = {
action: 'activate',
phase: row.title,
range: row.dateRange,
reason: warning.reason,
detail: warning.detail,
}
return
}
void activatePhase(row.title)
}
function requestPhaseActivationByTitle(phase: string) {
const row = timelineRows.value.find((item) => item.title === phase)
if (!row) {
void activatePhase(phase)
return
}
requestPhaseActivation(row)
}
function requestCompleteSeason() {
if (completing.value || !canCompleteSelectedSeason.value) return
if (!hasShowDayPassed(form)) {
phaseWarning.value = {
action: 'complete',
phase: 'Abgeschlossen',
range: form.showDate ? `nach ${formatLocalDate(form.showDate)}` : 'nach der Award Show',
reason: form.showDate
? `Die Award Show ist laut Zeitplan erst am ${formatLocalDate(form.showDate)} bzw. noch nicht vorbei.`
: 'Für die Award Show ist noch kein Datum hinterlegt.',
detail: 'Wenn du jetzt beendest, werden Public-Aktionen gesperrt und das Jahr als abgeschlossen behandelt.',
}
return
}
void completeSeason()
}
async function confirmPhaseWarning() {
const pending = phaseWarning.value
if (!pending) return
phaseWarning.value = null
if (pending.action === 'complete') {
await completeSeason()
return
}
await activatePhase(pending.phase)
}
// ---------- Quick stats ----------
const quickStats = computed(() => {
const d = seasonDetail.value
@@ -290,7 +414,7 @@ const readinessScore = computed(() => {
variant="ghost"
class="gap-1.5 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100"
:disabled="completing || !canCompleteSelectedSeason"
@click="completeSeason"
@click="requestCompleteSeason"
>
<CheckCircle2 class="h-4 w-4" /> {{ completing ? 'Schließt …' : 'Beenden' }}
</Button>
@@ -467,7 +591,7 @@ const readinessScore = computed(() => {
size="sm"
class="ml-2 border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
:disabled="saving || !selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
@click="autoPhase && !autoPhaseIsCompleted && activatePhase(autoPhase)"
@click="autoPhase && !autoPhaseIsCompleted && requestPhaseActivationByTitle(autoPhase)"
>
{{ autoPhaseIsCompleted ? 'Über Beenden' : 'Aktivieren' }}
</Button>
@@ -558,8 +682,8 @@ const readinessScore = computed(() => {
: row.finalLocked
? 'border border-slate-200 bg-slate-50 text-slate-500'
: 'border border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
:disabled="saving || !selectedSeasonId || row.active || row.finalLocked"
@click="activatePhase(row.title)"
:disabled="saving || !selectedSeasonId || row.active || row.finalLocked"
@click="requestPhaseActivation(row)"
>
<CheckCircle2 v-if="row.active" class="h-3.5 w-3.5" />
<LockKeyhole v-else-if="row.finalLocked" class="h-3.5 w-3.5" />
@@ -621,4 +745,48 @@ const readinessScore = computed(() => {
:on-close="() => { seasonToDelete = null }"
:on-confirm-delete="confirmDeleteSeason"
/>
<Modal
:open="!!phaseWarning"
title="Phase außerhalb des Zeitfensters"
subtitle="Der Zeitplan passt nicht zur gewählten manuellen Umstellung."
@close="phaseWarning = null"
>
<div v-if="phaseWarning" class="space-y-4">
<div class="flex gap-4 rounded-[24px] border border-amber-200 bg-amber-50 p-4 text-amber-900">
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
<div>
<p class="font-bold">{{ phaseWarning.reason }}</p>
<p class="mt-2 text-sm font-semibold leading-6 text-amber-800">{{ phaseWarning.detail }}</p>
</div>
</div>
<div class="grid gap-3 rounded-[22px] border border-violet-100 bg-white/80 p-4 text-sm">
<div class="flex items-center justify-between gap-4">
<span class="font-semibold text-slate-500">Gewählte Phase</span>
<strong class="text-right text-slate-900">{{ phaseWarning.phase }}</strong>
</div>
<div class="flex items-center justify-between gap-4">
<span class="font-semibold text-slate-500">Geplanter Zeitraum</span>
<strong class="text-right text-slate-900">{{ phaseWarning.range }}</strong>
</div>
<div class="flex items-center justify-between gap-4">
<span class="font-semibold text-slate-500">Heute</span>
<strong class="text-right text-slate-900">{{ todayLabel }}</strong>
</div>
</div>
<p class="rounded-2xl border border-violet-100 bg-violet-50/70 px-4 py-3 text-sm font-semibold leading-6 text-slate-600">
Bestätige nur, wenn der Zeitplan bewusst übersteuert werden soll. Nach der Bestätigung wird die Phase sofort gespeichert.
</p>
</div>
<template #footer>
<Button type="button" variant="ghost" :disabled="saving || completing" @click="phaseWarning = null">
Abbrechen
</Button>
<Button type="button" variant="secondary" :disabled="saving || completing" @click="confirmPhaseWarning">
{{ phaseWarning?.action === 'complete' ? 'Trotzdem beenden' : 'Trotzdem aktivieren' }}
</Button>
</template>
</Modal>
</template>