Files
vtuber-awards/frontend/src/components/admin/useAdminOperationalSettings.ts
T
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:32:21 +02:00

310 lines
11 KiB
TypeScript

import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ApiRequestError, api } from '../../lib/api'
import { clearSiteStatusCache } from '../../lib/siteStatus'
import type { AdminOperationalSettingsResponse } from '../../types/awards'
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary } from './adminSettingsTypes'
const fallbackMaintenanceTitle = 'Sternenpause'
const fallbackMaintenanceMessage = 'Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.'
export function useAdminOperationalSettings() {
const operationalLoading = ref(true)
const operationalSaving = ref(false)
const operationalError = ref('')
const operationalSuccess = ref('')
const demoPasswordSet = ref(false)
const demoManagedByDatabase = ref(false)
const demoPasswordInput = ref('')
const twitchClientSecretSet = ref(false)
const twitchAuthConfigured = ref(false)
const twitchAuthManagedByDatabase = ref(false)
const twitchClientSecretInput = ref('')
const savedOperationalSnapshot = ref('')
const operationalForm = reactive<AdminOperationalSettingsForm>({
demoLoginEnabled: false,
demoLoginIdentifier: '',
demoLoginTwitchUserId: '',
demoLoginDisplayName: '',
twitchClientId: '',
twitchRedirectUri: '',
twitchScope: '',
sessionIdleTimeoutHours: 3,
maintenanceModeEnabled: false,
maintenanceTitle: fallbackMaintenanceTitle,
maintenanceMessage: fallbackMaintenanceMessage,
})
const demoPasswordHint = computed(() => {
if (demoPasswordInput.value.trim()) {
return 'Dieses neue Passwort wird beim Speichern gesetzt.'
}
if (demoPasswordSet.value) {
return demoManagedByDatabase.value
? 'Passwort ist gesetzt. Leer lassen, wenn es bleiben soll.'
: 'Aktuell kommt das Passwort aus der Demo-Config. Beim Speichern wird es in die DB übernommen.'
}
return 'Noch kein Demo-Passwort gesetzt. Zum Aktivieren bitte ein Passwort vergeben.'
})
const demoCredentialsComplete = computed(() =>
!operationalForm.demoLoginEnabled
|| (
Boolean(operationalForm.demoLoginIdentifier.trim())
&& Boolean(operationalForm.demoLoginTwitchUserId.trim())
&& Boolean(operationalForm.demoLoginDisplayName.trim())
&& (demoPasswordSet.value || Boolean(demoPasswordInput.value.trim()))
),
)
const twitchAuthComplete = computed(() =>
Boolean(operationalForm.twitchClientId.trim())
&& (twitchClientSecretSet.value || Boolean(twitchClientSecretInput.value.trim()))
)
const twitchSecretHint = computed(() => {
if (twitchClientSecretInput.value.trim()) {
return 'Dieses neue Secret wird beim Speichern gesetzt.'
}
if (twitchClientSecretSet.value) {
return 'Client Secret ist gesetzt. Leer lassen, wenn es bleiben soll.'
}
return 'Noch kein Client Secret gesetzt. Beim ersten Speichern erforderlich.'
})
const operationalSummary = computed<AdminSettingsStatusSummary[]>(() => [
{
label: 'Demo Login',
value: operationalForm.demoLoginEnabled ? 'Aktiv' : 'Aus',
note: operationalForm.demoLoginEnabled
? demoCredentialsComplete.value
? 'Öffentlicher Zugriff ist geschützt.'
: 'Aktivierung braucht vollständige Zugangsdaten.'
: 'Landingpage ist öffentlich erreichbar.',
tone: operationalForm.demoLoginEnabled
? demoCredentialsComplete.value ? 'warning' : 'danger'
: 'good',
},
{
label: 'Session Timeout',
value: `${operationalForm.sessionIdleTimeoutHours}h`,
note: `Logout nach ${operationalForm.sessionIdleTimeoutHours} Stunden ohne Aktivitaet.`,
tone: operationalForm.sessionIdleTimeoutHours >= 3 ? 'good' : 'danger',
},
{
label: 'Wartungsmodus',
value: operationalForm.maintenanceModeEnabled ? 'Aktiv' : 'Aus',
note: operationalForm.maintenanceModeEnabled
? 'Besucher sehen die Wartungsseite.'
: 'Öffentliche Seiten werden normal ausgeliefert.',
tone: operationalForm.maintenanceModeEnabled ? 'warning' : 'good',
},
{
label: 'Twitch OAuth',
value: twitchAuthConfigured.value ? 'Konfiguriert' : 'Fehlt',
note: twitchAuthConfigured.value
? twitchAuthManagedByDatabase.value ? 'Quelle: Datenbank' : 'Quelle: App-Konfiguration'
: 'Offizieller Twitch Login ist noch nicht aktiv.',
tone: twitchAuthConfigured.value ? 'good' : 'warning',
},
{
label: 'Passwort',
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
note: demoManagedByDatabase.value ? 'Quelle: Datenbank' : 'Quelle: App-Konfiguration',
tone: demoPasswordSet.value ? 'good' : 'warning',
},
])
const hasUnsavedOperationalChanges = computed(() =>
Boolean(savedOperationalSnapshot.value) &&
buildOperationalSnapshot() !== savedOperationalSnapshot.value,
)
function applyOperationalSettings(response: AdminOperationalSettingsResponse) {
operationalForm.demoLoginEnabled = response.demoLoginEnabled
operationalForm.demoLoginIdentifier = response.demoLoginEmail
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
operationalForm.twitchClientId = response.twitchClientId
operationalForm.twitchRedirectUri = response.twitchRedirectUri
operationalForm.twitchScope = response.twitchScope
operationalForm.sessionIdleTimeoutHours = response.sessionIdleTimeoutHours
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
operationalForm.maintenanceTitle = response.maintenanceTitle
operationalForm.maintenanceMessage = response.maintenanceMessage
demoPasswordSet.value = response.demoLoginPasswordSet
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
twitchClientSecretSet.value = response.twitchClientSecretSet
twitchAuthConfigured.value = response.twitchAuthConfigured
twitchAuthManagedByDatabase.value = response.twitchAuthManagedByDatabase
demoPasswordInput.value = ''
twitchClientSecretInput.value = ''
rememberSavedOperationalSettings()
}
function buildOperationalSnapshot() {
return JSON.stringify({
demoLoginEnabled: operationalForm.demoLoginEnabled,
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
twitchClientId: operationalForm.twitchClientId,
twitchRedirectUri: operationalForm.twitchRedirectUri,
twitchScope: operationalForm.twitchScope,
sessionIdleTimeoutHours: operationalForm.sessionIdleTimeoutHours,
twitchClientSecretInput: twitchClientSecretInput.value,
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
maintenanceTitle: operationalForm.maintenanceTitle,
maintenanceMessage: operationalForm.maintenanceMessage,
demoPasswordInput: demoPasswordInput.value,
})
}
function rememberSavedOperationalSettings() {
savedOperationalSnapshot.value = buildOperationalSnapshot()
}
async function loadOperationalSettings(options: { silent?: boolean } = {}) {
if (!options.silent) {
operationalLoading.value = true
operationalError.value = ''
}
try {
applyOperationalSettings(await api.getAdminOperationalSettings())
} catch (error) {
operationalError.value = error instanceof ApiRequestError
? error.message
: 'Operational Settings konnten nicht geladen werden.'
} finally {
if (!options.silent) {
operationalLoading.value = false
}
}
}
function validateOperationalSettings() {
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 10) {
operationalError.value = 'Das Demo-Passwort muss mindestens 10 Zeichen lang sein.'
return false
}
if (operationalForm.demoLoginEnabled && !demoCredentialsComplete.value) {
operationalError.value = 'Demo-Login braucht Login, Twitch-ID, Anzeigenamen und ein gesetztes Passwort.'
return false
}
const hasAnyTwitchAuthInput = Boolean(
operationalForm.twitchClientId.trim()
|| operationalForm.twitchRedirectUri.trim()
|| operationalForm.twitchScope.trim()
|| twitchClientSecretInput.value.trim()
|| twitchClientSecretSet.value,
)
if (hasAnyTwitchAuthInput && !operationalForm.twitchClientId.trim()) {
operationalError.value = 'Twitch OAuth braucht eine Client-ID.'
return false
}
if (operationalForm.twitchClientId.trim() && !twitchAuthComplete.value) {
operationalError.value = 'Twitch OAuth braucht beim ersten Speichern ein Client Secret.'
return false
}
if (!Number.isInteger(operationalForm.sessionIdleTimeoutHours) || operationalForm.sessionIdleTimeoutHours < 3) {
operationalError.value = 'Session-Timeout muss mindestens 3 Stunden betragen.'
return false
}
return true
}
async function saveOperationalSettings() {
operationalError.value = ''
operationalSuccess.value = ''
if (!validateOperationalSettings()) {
return
}
operationalSaving.value = true
try {
const result = await api.updateAdminOperationalSettings({
...operationalForm,
demoLoginEmail: operationalForm.demoLoginIdentifier,
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
twitchClientSecret: twitchClientSecretInput.value.trim() || undefined,
})
demoPasswordSet.value = result.demoLoginPasswordSet
demoManagedByDatabase.value = true
twitchClientSecretSet.value = result.twitchClientSecretSet
demoPasswordInput.value = ''
twitchClientSecretInput.value = ''
await loadOperationalSettings({ silent: true })
clearSiteStatusCache()
operationalSuccess.value = 'Demo-Zugang, Session-Timeout und Wartungsmodus wurden gespeichert.'
} catch (error) {
operationalError.value = error instanceof ApiRequestError
? error.message
: 'Operational Settings konnten nicht gespeichert werden.'
} finally {
operationalSaving.value = false
}
}
watch(
() => [
operationalForm.demoLoginEnabled,
operationalForm.demoLoginIdentifier,
operationalForm.demoLoginTwitchUserId,
operationalForm.demoLoginDisplayName,
operationalForm.twitchClientId,
operationalForm.twitchRedirectUri,
operationalForm.twitchScope,
operationalForm.sessionIdleTimeoutHours,
twitchClientSecretInput.value,
operationalForm.maintenanceModeEnabled,
operationalForm.maintenanceTitle,
operationalForm.maintenanceMessage,
demoPasswordInput.value,
],
() => {
if (operationalLoading.value || operationalSaving.value) return
operationalError.value = ''
operationalSuccess.value = ''
},
)
onMounted(loadOperationalSettings)
return {
operationalLoading,
operationalSaving,
operationalError,
operationalSuccess,
operationalForm,
demoPasswordSet,
demoManagedByDatabase,
demoPasswordInput,
twitchClientSecretSet,
twitchAuthConfigured,
twitchAuthManagedByDatabase,
twitchClientSecretInput,
demoPasswordHint,
twitchSecretHint,
demoCredentialsComplete,
twitchAuthComplete,
operationalSummary,
hasUnsavedOperationalChanges,
loadOperationalSettings,
saveOperationalSettings,
}
}