Harden audit privacy and admin modals
This commit is contained in:
@@ -43,7 +43,7 @@ public sealed record AdminAuditEntryDto(
|
|||||||
string Summary,
|
string Summary,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
string MetadataJson,
|
string MetadataJson,
|
||||||
string CreatedFromIp,
|
string? CreatedFromIp,
|
||||||
string UserAgent);
|
string UserAgent);
|
||||||
|
|
||||||
public sealed record AdminAuditEntriesResponse(
|
public sealed record AdminAuditEntriesResponse(
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ public static class AdminDashboardEndpoints
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetDashboard(AwardsDbContext db)
|
private static async Task<IResult> GetDashboard(AwardsDbContext db, HttpContext context)
|
||||||
{
|
{
|
||||||
|
var canViewAuditIp = CanViewAuditIp(context);
|
||||||
var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
|
var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
|
||||||
if (currentSeason is null)
|
if (currentSeason is null)
|
||||||
{
|
{
|
||||||
@@ -68,7 +69,7 @@ public static class AdminDashboardEndpoints
|
|||||||
item.Summary,
|
item.Summary,
|
||||||
item.CreatedAt,
|
item.CreatedAt,
|
||||||
item.MetadataJson,
|
item.MetadataJson,
|
||||||
item.CreatedFromIp,
|
canViewAuditIp ? item.CreatedFromIp : null,
|
||||||
item.UserAgent))
|
item.UserAgent))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
@@ -101,10 +102,12 @@ public static class AdminDashboardEndpoints
|
|||||||
DateTimeOffset? from,
|
DateTimeOffset? from,
|
||||||
DateTimeOffset? to,
|
DateTimeOffset? to,
|
||||||
string? cursor,
|
string? cursor,
|
||||||
AwardsDbContext db)
|
AwardsDbContext db,
|
||||||
|
HttpContext context)
|
||||||
{
|
{
|
||||||
var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500);
|
var normalizedLimit = Math.Clamp(limit ?? 100, 1, 500);
|
||||||
var search = query?.Trim();
|
var search = query?.Trim();
|
||||||
|
var canViewAuditIp = CanViewAuditIp(context);
|
||||||
var auditQuery = db.AdminAuditEntries.AsNoTracking();
|
var auditQuery = db.AdminAuditEntries.AsNoTracking();
|
||||||
|
|
||||||
if (!TryDecodeAuditCursor(cursor, out var decodedCursor))
|
if (!TryDecodeAuditCursor(cursor, out var decodedCursor))
|
||||||
@@ -122,8 +125,8 @@ public static class AdminDashboardEndpoints
|
|||||||
EF.Functions.ILike(item.EntityId, pattern) ||
|
EF.Functions.ILike(item.EntityId, pattern) ||
|
||||||
EF.Functions.ILike(item.Summary, pattern) ||
|
EF.Functions.ILike(item.Summary, pattern) ||
|
||||||
EF.Functions.ILike(item.MetadataJson, 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))
|
if (!string.IsNullOrWhiteSpace(admin))
|
||||||
@@ -176,7 +179,7 @@ public static class AdminDashboardEndpoints
|
|||||||
item.Summary,
|
item.Summary,
|
||||||
item.CreatedAt,
|
item.CreatedAt,
|
||||||
item.MetadataJson,
|
item.MetadataJson,
|
||||||
item.CreatedFromIp,
|
canViewAuditIp ? item.CreatedFromIp : null,
|
||||||
item.UserAgent))
|
item.UserAgent))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
@@ -197,6 +200,9 @@ public static class AdminDashboardEndpoints
|
|||||||
private static string EncodeAuditCursor(AdminAuditEntryDto entry) =>
|
private static string EncodeAuditCursor(AdminAuditEntryDto entry) =>
|
||||||
$"{entry.CreatedAt.UtcTicks}:{entry.Id}";
|
$"{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)
|
private static bool TryDecodeAuditCursor(string? cursor, out AuditCursor? decodedCursor)
|
||||||
{
|
{
|
||||||
decodedCursor = null;
|
decodedCursor = null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { LogOut, Shield, Trash2, Unlink, X } from '@lucide/vue'
|
||||||
|
|
||||||
import type { AuthSession } from '../types/awards'
|
import type { AuthSession } from '../types/awards'
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ defineEmits<{
|
|||||||
const isTeamPasswordSession = computed(() => Boolean(props.session?.teamLogin && props.session.twitchUserId.startsWith('team:')))
|
const isTeamPasswordSession = computed(() => Boolean(props.session?.teamLogin && props.session.twitchUserId.startsWith('team:')))
|
||||||
const isTwitchAuthenticated = computed(() => Boolean(props.session && !isTeamPasswordSession.value))
|
const isTwitchAuthenticated = computed(() => Boolean(props.session && !isTeamPasswordSession.value))
|
||||||
const profileHandle = computed(() => props.session?.teamLogin ?? props.session?.twitchUserId ?? '')
|
const profileHandle = computed(() => props.session?.teamLogin ?? props.session?.twitchUserId ?? '')
|
||||||
|
const displayInitial = computed(() => (props.session?.displayName ?? profileHandle.value).charAt(0).toUpperCase())
|
||||||
const role = computed(() => props.session?.role ?? 'viewer')
|
const role = computed(() => props.session?.role ?? 'viewer')
|
||||||
const isTeamSession = computed(() => Boolean(props.session?.teamLogin))
|
const isTeamSession = computed(() => Boolean(props.session?.teamLogin))
|
||||||
const isParticipantSession = computed(() => !isTeamSession.value)
|
const isParticipantSession = computed(() => !isTeamSession.value)
|
||||||
@@ -52,220 +54,269 @@ const logoutLabel = computed(() => !isTeamSession.value && isTwitchAuthenticated
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- ── Account Modal ─────────────────────────────────────── -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<Transition
|
||||||
v-if="accountOpen"
|
enter-active-class="transition duration-200 ease-out"
|
||||||
style="position:fixed;inset:0;z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
enter-from-class="opacity-0"
|
||||||
@click.self="$emit('close-account')"
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
>
|
>
|
||||||
<div style="position:relative;width:100%;max-width:480px;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);overflow:hidden;">
|
<div
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 28px 18px;border-bottom:1px solid #f1ecfb;">
|
v-if="accountOpen"
|
||||||
<div>
|
class="fixed inset-0 z-[300] flex items-end justify-center p-4 sm:items-center sm:p-6"
|
||||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Mein Profil</div>
|
style="background:rgba(40,24,70,0.52);backdrop-filter:blur(6px);"
|
||||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:24px;margin:0;color:#3f3556;">@{{ profileHandle }}</h2>
|
@click.self="$emit('close-account')"
|
||||||
</div>
|
>
|
||||||
<button
|
<Transition
|
||||||
style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;"
|
enter-active-class="transition duration-200 ease-out"
|
||||||
@click="$emit('close-account')"
|
enter-from-class="opacity-0 translate-y-4 scale-[0.97] sm:translate-y-0"
|
||||||
>
|
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||||
✕
|
>
|
||||||
</button>
|
<div class="w-full max-w-[480px] overflow-hidden rounded-3xl bg-white shadow-[0_40px_90px_rgba(40,24,70,0.38)]">
|
||||||
</div>
|
|
||||||
<div style="padding:24px 28px;display:flex;flex-direction:column;gap:16px;">
|
<!-- Header -->
|
||||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px;padding:14px 16px;border-radius:16px;background:#f6f1fd;border:1px solid #ede4fb;">
|
<div class="flex items-center justify-between gap-4 border-b border-[#f1ecfb] bg-gradient-to-br from-[#f7f2ff] to-[#fff7ed] px-7 py-5">
|
||||||
<svg v-if="isTeamPasswordSession" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<div class="flex items-center gap-3.5 min-w-0">
|
||||||
<path d="M15 7a4 4 0 1 1-2.8-3.82" />
|
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-gradient-to-br from-[#8b6cdb] to-[#7355c8] text-base font-bold text-white shadow-[0_6px_18px_rgba(124,86,196,0.3)]">
|
||||||
<path d="M13 5 21 13" />
|
{{ displayInitial }}
|
||||||
<path d="M21 13 18 16" />
|
</div>
|
||||||
<path d="M18 13 16 15" />
|
<div class="min-w-0">
|
||||||
</svg>
|
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Mein Profil</p>
|
||||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="#9146FF" aria-hidden="true">
|
<h2 class="truncate font-['Cormorant_Garamond'] text-2xl font-bold leading-tight text-[#3f3556]">
|
||||||
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z" />
|
@{{ profileHandle }}
|
||||||
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z" />
|
</h2>
|
||||||
</svg>
|
</div>
|
||||||
<span style="font-size:14px;color:#5f44ad;font-weight:800;">{{ authStatusLabel }}</span>
|
|
||||||
<span style="grid-column:2;font-size:12.5px;color:#6f6685;line-height:1.45;">{{ authStatusText }}</span>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px;border-radius:14px;background:#fafafa;border:1px solid #f0eafc;">
|
|
||||||
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a99fc0;margin:0 0 10px;">Gespeicherte Daten</p>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:7px;">
|
|
||||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
|
||||||
<span style="color:#6f6685;">{{ primaryIdentityLabel }}</span>
|
|
||||||
<span style="font-weight:600;color:#3f3556;">@{{ profileHandle }}</span>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
|
||||||
<span style="color:#6f6685;">Rolle</span>
|
|
||||||
<span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ role }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="showTeamLoginDetail" style="display:flex;justify-content:space-between;font-size:13.5px;">
|
|
||||||
<span style="color:#6f6685;">Team-Login</span>
|
|
||||||
<span style="font-weight:600;color:#3f3556;">@{{ session?.teamLogin }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="session?.boundTwitchUserId" style="display:flex;justify-content:space-between;font-size:13.5px;">
|
|
||||||
<span style="color:#6f6685;">Gebundenes Twitch</span>
|
|
||||||
<span style="font-weight:600;color:#3f3556;">@{{ session.boundTwitchUserId }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="isParticipantSession" style="display:flex;justify-content:space-between;font-size:13.5px;">
|
|
||||||
<span style="color:#6f6685;">Einreichungen</span>
|
|
||||||
<span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="isParticipantSession" style="display:flex;justify-content:space-between;font-size:13.5px;">
|
|
||||||
<span style="color:#6f6685;">Löschfrist</span>
|
|
||||||
<span style="font-weight:600;color:#059669;">März 2027 (auto.)</span>
|
|
||||||
</div>
|
|
||||||
<div v-else style="display:flex;justify-content:space-between;gap:16px;font-size:13.5px;">
|
|
||||||
<span style="color:#6f6685;">Accountverwaltung</span>
|
|
||||||
<span style="font-weight:600;color:#3f3556;text-align:right;">Team-Accounts bleiben bestehen und werden im Admin-Panel verwaltet.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[#f1ecfb] text-[#8b6cdb] transition hover:bg-[#e6dcf6]"
|
||||||
|
aria-label="Profil schließen"
|
||||||
|
@click="$emit('close-account')"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<section
|
<!-- Scrollable body -->
|
||||||
v-if="isTeamSession"
|
<div class="max-h-[72vh] overflow-y-auto">
|
||||||
style="padding:16px;border-radius:14px;background:#f8f5ff;border:1px solid #ede4fb;display:grid;gap:12px;"
|
<div class="space-y-3 p-6">
|
||||||
>
|
|
||||||
<div>
|
<!-- Auth status -->
|
||||||
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#8b6cdb;margin:0 0 4px;">Optionaler Admin-Login</p>
|
<div class="grid grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-2xl border border-[#ede4fb] bg-[#f6f1fd] px-4 py-3.5">
|
||||||
<p v-if="hasBoundTwitch" style="font-size:12.5px;color:#6f6685;margin:0;line-height:1.45;">
|
<div class="mt-0.5 shrink-0">
|
||||||
Dein Team-Account ist mit Twitch verbunden. Entfernst du die Verknüpfung, ist der Admin-Login per Twitch nicht mehr möglich.
|
<svg v-if="isTeamPasswordSession" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
</p>
|
<path d="M15 7a4 4 0 1 1-2.8-3.82"/><path d="M13 5 21 13"/><path d="M21 13 18 16"/><path d="M18 13 16 15"/>
|
||||||
<p v-else style="font-size:12.5px;color:#6f6685;margin:0;line-height:1.45;">
|
</svg>
|
||||||
Verbinde deinen privaten Twitch-Account über den offiziellen Twitch Login. Danach kannst du dich im Admin-Panel per Twitch anmelden.
|
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="#9146FF" aria-hidden="true">
|
||||||
</p>
|
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/>
|
||||||
</div>
|
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/>
|
||||||
<div v-if="session?.boundTwitchUserId" style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 12px;border-radius:12px;background:white;border:1px solid #ede4fb;font-size:13px;">
|
</svg>
|
||||||
<span style="color:#6f6685;">Aktuell verbunden</span>
|
</div>
|
||||||
<span style="font-weight:800;color:#3f3556;">@{{ session.boundTwitchUserId }}</span>
|
<span class="text-sm font-extrabold text-[#5f44ad]">{{ authStatusLabel }}</span>
|
||||||
</div>
|
<span class="col-start-2 text-xs leading-relaxed text-[#6f6685]">{{ authStatusText }}</span>
|
||||||
<p v-if="session?.mustChangePassword" style="font-size:12.5px;color:#b45309;margin:0;font-weight:700;">Bitte ändere zuerst dein temporäres Passwort.</p>
|
</div>
|
||||||
<p v-if="accountActionError" style="font-size:12.5px;color:#be123c;margin:0;font-weight:700;">{{ accountActionError }}</p>
|
|
||||||
<p v-if="accountActionSuccess" style="font-size:12.5px;color:#047857;margin:0;font-weight:700;">{{ accountActionSuccess }}</p>
|
<!-- Saved data -->
|
||||||
<button
|
<div class="rounded-2xl border border-[#ede4fb] bg-[#fafafa] px-4 py-3.5">
|
||||||
v-if="!hasBoundTwitch"
|
<p class="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#a99fc0]">Gespeicherte Daten</p>
|
||||||
:disabled="authLoading || !canBindTwitch"
|
<div class="space-y-2">
|
||||||
type="button"
|
<div class="flex items-center justify-between text-sm">
|
||||||
style="display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 14px;border-radius:12px;border:none;background:#6f4fd1;color:white;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:800;cursor:pointer;disabled:opacity:.6;"
|
<span class="text-[#6f6685]">{{ primaryIdentityLabel }}</span>
|
||||||
@click="$emit('bind-team-twitch')"
|
<span class="font-semibold text-[#3f3556]">@{{ profileHandle }}</span>
|
||||||
>
|
</div>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z" />
|
<span class="text-[#6f6685]">Rolle</span>
|
||||||
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z" />
|
<span class="text-[10px] font-bold uppercase tracking-[0.1em] text-[#8b6cdb]">{{ role }}</span>
|
||||||
</svg>
|
</div>
|
||||||
{{ authLoading ? 'Twitch wird geöffnet...' : 'Mit Twitch verbinden' }}
|
<div v-if="showTeamLoginDetail" class="flex items-center justify-between text-sm">
|
||||||
</button>
|
<span class="text-[#6f6685]">Team-Login</span>
|
||||||
<button
|
<span class="font-semibold text-[#3f3556]">@{{ session?.teamLogin }}</span>
|
||||||
v-else
|
</div>
|
||||||
:disabled="authLoading"
|
<div v-if="session?.boundTwitchUserId" class="flex items-center justify-between text-sm">
|
||||||
type="button"
|
<span class="text-[#6f6685]">Gebundenes Twitch</span>
|
||||||
style="display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 14px;border-radius:12px;border:1px solid #fecdd3;background:#fff5f5;color:#e11d48;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:800;cursor:pointer;disabled:opacity:.6;"
|
<span class="font-semibold text-[#3f3556]">@{{ session.boundTwitchUserId }}</span>
|
||||||
@click="$emit('disconnect-team-twitch')"
|
</div>
|
||||||
>
|
<template v-if="isParticipantSession">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<path d="M18 6 6 18" />
|
<span class="text-[#6f6685]">Einreichungen</span>
|
||||||
<path d="m6 6 12 12" />
|
<span class="font-semibold text-[#3f3556]">Clips, Votes, Nominierungen</span>
|
||||||
</svg>
|
</div>
|
||||||
{{ authLoading ? 'Entknüpft...' : 'Twitch entknüpfen' }}
|
</template>
|
||||||
</button>
|
<template v-else>
|
||||||
</section>
|
<div class="flex items-start justify-between gap-4 text-sm">
|
||||||
<button
|
<span class="shrink-0 text-[#6f6685]">Accountverwaltung</span>
|
||||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #ede4fb;background:#f9f6ff;color:#6a4fb8;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
<span class="text-right font-semibold text-[#3f3556]">Team-Accounts werden im Admin-Panel verwaltet.</span>
|
||||||
@click="$emit('open-privacy')"
|
</div>
|
||||||
>
|
</template>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
</div>
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
</div>
|
||||||
</svg>
|
|
||||||
Datenschutzerklärung lesen
|
<!-- Team Twitch binding -->
|
||||||
</button>
|
<section v-if="isTeamSession" class="rounded-2xl border border-[#ede4fb] bg-[#f8f5ff] p-4">
|
||||||
<button
|
<p class="text-[11px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Optionaler Admin-Login</p>
|
||||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #e2e8f0;background:#f8fafc;color:#64748b;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
<p v-if="hasBoundTwitch" class="mt-1.5 text-xs leading-relaxed text-[#6f6685]">
|
||||||
@click="$emit('logout')"
|
Dein Team-Account ist mit Twitch verbunden. Entfernst du die Verknüpfung, ist der Admin-Login per Twitch nicht mehr möglich.
|
||||||
>
|
</p>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<p v-else class="mt-1.5 text-xs leading-relaxed text-[#6f6685]">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
Verbinde deinen privaten Twitch-Account. Danach kannst du dich im Admin-Panel per Twitch anmelden.
|
||||||
<polyline points="16 17 21 12 16 7" />
|
</p>
|
||||||
<line x1="21" y1="12" x2="9" y2="12" />
|
<div v-if="session?.boundTwitchUserId" class="mt-3 flex items-center justify-between rounded-xl border border-[#ede4fb] bg-white px-3.5 py-2.5 text-sm">
|
||||||
</svg>
|
<span class="text-[#6f6685]">Aktuell verbunden</span>
|
||||||
{{ logoutLabel }}
|
<span class="font-extrabold text-[#3f3556]">@{{ session.boundTwitchUserId }}</span>
|
||||||
</button>
|
</div>
|
||||||
<template v-if="isParticipantSession && !deleteConfirm">
|
<p v-if="session?.mustChangePassword" class="mt-2 text-xs font-bold text-amber-600">Bitte ändere zuerst dein temporäres Passwort.</p>
|
||||||
<button
|
<p v-if="accountActionError" class="mt-2 text-xs font-bold text-rose-600">{{ accountActionError }}</p>
|
||||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #fecdd3;background:#fff5f5;color:#e11d48;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
<p v-if="accountActionSuccess" class="mt-2 text-xs font-bold text-emerald-600">{{ accountActionSuccess }}</p>
|
||||||
@click="$emit('request-delete')"
|
<button
|
||||||
>
|
v-if="!hasBoundTwitch"
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
:disabled="authLoading || !canBindTwitch"
|
||||||
<polyline points="3 6 5 6 21 6" />
|
type="button"
|
||||||
<path d="M19 6l-1 14H6L5 6" />
|
class="mt-3 flex w-full items-center justify-center gap-2 rounded-xl bg-[#6f4fd1] px-4 py-2.5 text-sm font-extrabold text-white transition hover:bg-[#5f44ad] disabled:opacity-50"
|
||||||
<path d="M10 11v6M14 11v6" />
|
@click="$emit('bind-team-twitch')"
|
||||||
</svg>
|
>
|
||||||
Meine Daten löschen (Löschrecht Art. 17 DSGVO)
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
</button>
|
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/>
|
||||||
</template>
|
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/>
|
||||||
<template v-else-if="isParticipantSession">
|
</svg>
|
||||||
<div style="padding:16px;border-radius:14px;background:#fff5f5;border:1.5px solid #fecdd3;">
|
{{ authLoading ? 'Twitch wird geöffnet …' : 'Mit Twitch verbinden' }}
|
||||||
<p style="font-size:13.5px;color:#9f1239;font-weight:600;margin:0 0 6px;">Alle deine Daten werden sofort gelöscht.</p>
|
</button>
|
||||||
<p style="font-size:12.5px;color:#e11d48;margin:0 0 14px;line-height:1.5;">Das umfasst deine Votes, Nominierungen und Clip-Einreichungen. Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
<button
|
||||||
<p v-if="accountActionError" style="font-size:12.5px;color:#be123c;margin:0 0 12px;line-height:1.5;font-weight:600;">{{ accountActionError }}</p>
|
v-else
|
||||||
<div style="display:flex;gap:8px;">
|
:disabled="authLoading"
|
||||||
<button :disabled="authLoading" style="flex:1;padding:10px;border-radius:10px;border:1px solid #e6dcf6;background:white;color:#6f6685;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;cursor:pointer;" @click="$emit('cancel-delete')">Abbrechen</button>
|
type="button"
|
||||||
<button :disabled="authLoading" style="flex:1;padding:10px;border-radius:10px;border:none;background:#e11d48;color:white;font-family:'Outfit',sans-serif;font-weight:700;font-size:13px;cursor:pointer;" @click="$emit('confirm-delete')">
|
class="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm font-extrabold text-rose-600 transition hover:bg-rose-100 disabled:opacity-50"
|
||||||
{{ authLoading ? 'Löscht …' : 'Jetzt löschen' }}
|
@click="$emit('disconnect-team-twitch')"
|
||||||
|
>
|
||||||
|
<Unlink class="h-4 w-4" />
|
||||||
|
{{ authLoading ? 'Entknüpft …' : 'Twitch entknüpfen' }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Privacy -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded-xl border border-[#ede4fb] bg-[#f9f6ff] px-4 py-3 text-left text-sm font-semibold text-[#6a4fb8] transition hover:bg-[#f1ecfb]"
|
||||||
|
@click="$emit('open-privacy')"
|
||||||
|
>
|
||||||
|
<Shield class="h-4 w-4 shrink-0" />
|
||||||
|
Datenschutzerklärung lesen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left text-sm font-semibold text-slate-500 transition hover:bg-slate-100"
|
||||||
|
@click="$emit('logout')"
|
||||||
|
>
|
||||||
|
<LogOut class="h-4 w-4 shrink-0" />
|
||||||
|
{{ logoutLabel }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete request -->
|
||||||
|
<template v-if="isParticipantSession && !deleteConfirm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-left text-sm font-semibold text-rose-600 transition hover:bg-rose-100"
|
||||||
|
@click="$emit('request-delete')"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 shrink-0" />
|
||||||
|
Meine Daten löschen (Löschrecht Art. 17 DSGVO)
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<template v-else-if="isParticipantSession">
|
||||||
|
<div class="rounded-2xl border-[1.5px] border-rose-200 bg-rose-50 p-4">
|
||||||
|
<p class="text-sm font-semibold text-rose-800">Alle deine Daten werden sofort gelöscht.</p>
|
||||||
|
<p class="mt-1 text-xs leading-relaxed text-rose-600">Das umfasst Votes, Nominierungen und Clip-Einreichungen. Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||||
|
<p v-if="accountActionError" class="mt-2 text-xs font-bold text-rose-700">{{ accountActionError }}</p>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
:disabled="authLoading"
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-xl border border-[#e6dcf6] bg-white py-2.5 text-sm font-semibold text-[#6f6685] transition hover:bg-[#f9f6ff] disabled:opacity-50"
|
||||||
|
@click="$emit('cancel-delete')"
|
||||||
|
>Abbrechen</button>
|
||||||
|
<button
|
||||||
|
:disabled="authLoading"
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-xl bg-rose-600 py-2.5 text-sm font-bold text-white transition hover:bg-rose-700 disabled:opacity-50"
|
||||||
|
@click="$emit('confirm-delete')"
|
||||||
|
>{{ authLoading ? 'Löscht …' : 'Jetzt löschen' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- ── Privacy Modal ─────────────────────────────────────── -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<Transition
|
||||||
v-if="privacyOpen"
|
enter-active-class="transition duration-200 ease-out"
|
||||||
style="position:fixed;inset:0;z-index:400;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
enter-from-class="opacity-0"
|
||||||
@click.self="$emit('close-privacy')"
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
>
|
>
|
||||||
<div style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
<div
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
|
v-if="privacyOpen"
|
||||||
<div>
|
class="fixed inset-0 z-[400] flex items-center justify-center p-6"
|
||||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Rechtliches</div>
|
style="background:rgba(40,24,70,0.52);backdrop-filter:blur(6px);"
|
||||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">Datenschutzerklärung</h2>
|
@click.self="$emit('close-privacy')"
|
||||||
</div>
|
>
|
||||||
<button style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" @click="$emit('close-privacy')">✕</button>
|
<div class="flex max-h-[88vh] w-full max-w-[680px] flex-col overflow-hidden rounded-3xl bg-white shadow-[0_40px_90px_rgba(40,24,70,0.4)]">
|
||||||
</div>
|
<div class="flex shrink-0 items-center justify-between gap-4 border-b border-[#f1ecfb] px-8 py-5">
|
||||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
|
<div>
|
||||||
<template v-if="privacyContentHtml">
|
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Rechtliches</p>
|
||||||
<div class="app-shell-privacy-content" v-html="privacyContentHtml" />
|
<h2 class="font-['Cormorant_Garamond'] text-2xl font-bold text-[#3f3556]">Datenschutzerklärung</h2>
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div style="background:#f9f6ff;border:1px solid #ede4fb;border-radius:14px;padding:16px;">
|
|
||||||
<h3 style="font-size:15px;font-weight:700;color:#3f3556;margin:0 0 8px;">Datenschutztext wird geladen</h3>
|
|
||||||
<p style="margin:0;">Die Datenschutzerklärung wird aus der Landingpage-Konfiguration geladen.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<button
|
||||||
<p v-if="privacyEmail" style="font-size:12px;color:#a99fc0;margin:18px 0 0;">Kontakt: {{ privacyEmail }}</p>
|
type="button"
|
||||||
|
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[#f1ecfb] text-[#8b6cdb] transition hover:bg-[#e6dcf6]"
|
||||||
|
aria-label="Datenschutz schließen"
|
||||||
|
@click="$emit('close-privacy')"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto px-8 py-7 text-sm leading-relaxed text-[#6f6685]">
|
||||||
|
<div v-if="privacyContentHtml" class="privacy-content" v-html="privacyContentHtml" />
|
||||||
|
<div v-else class="rounded-2xl border border-[#f1ecfb] bg-[#fcfbff] px-4 py-3 text-sm">
|
||||||
|
Datenschutzerklärung wird geladen.
|
||||||
|
</div>
|
||||||
|
<p v-if="privacyEmail" class="mt-5 text-xs text-[#a99fc0]">Kontakt: {{ privacyEmail }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-shell-privacy-content :deep(p) {
|
.privacy-content :deep(p) {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
}
|
}
|
||||||
|
.privacy-content :deep(p:last-child),
|
||||||
.app-shell-privacy-content :deep(p:last-child),
|
.privacy-content :deep(ul:last-child),
|
||||||
.app-shell-privacy-content :deep(ul:last-child),
|
.privacy-content :deep(ol:last-child) {
|
||||||
.app-shell-privacy-content :deep(ol:last-child) {
|
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
.privacy-content :deep(ul),
|
||||||
.app-shell-privacy-content :deep(ul),
|
.privacy-content :deep(ol) {
|
||||||
.app-shell-privacy-content :deep(ol) {
|
|
||||||
margin: 0 0 14px 20px;
|
margin: 0 0 14px 20px;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
.privacy-content :deep(li) {
|
||||||
.app-shell-privacy-content :deep(li) {
|
|
||||||
margin: 3px 0;
|
margin: 3px 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -128,9 +128,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Braces, ExternalLink, GitCompareArrows, ListTree, ShieldCheck, X } from '@lucide/vue'
|
||||||
|
|
||||||
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
import type { AuditLogRow } from './useAdminAuditManager'
|
import type { AuditLogRow } from './useAdminAuditManager'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -145,18 +146,10 @@ function closeOnEscape(event: KeyboardEvent) {
|
|||||||
if (event.key === 'Escape' && props.entry) emit('close')
|
if (event.key === 'Escape' && props.entry) emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
useBodyScrollLock(() => Boolean(props.entry))
|
||||||
() => props.entry,
|
|
||||||
(entry) => {
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.body.style.overflow = entry ? 'hidden' : ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => window.addEventListener('keydown', closeOnEscape))
|
onMounted(() => window.addEventListener('keydown', closeOnEscape))
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('keydown', closeOnEscape)
|
window.removeEventListener('keydown', closeOnEscape)
|
||||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
import { X } from '@lucide/vue'
|
import { X } from '@lucide/vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
import type { FaqFormItem } from './adminContentTypes'
|
import type { FaqFormItem } from './adminContentTypes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -53,6 +54,8 @@ const props = defineProps<{
|
|||||||
faq: FaqFormItem[]
|
faq: FaqFormItem[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
useBodyScrollLock(() => props.open)
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -39,13 +39,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { X } from '@lucide/vue'
|
import { X } from '@lucide/vue'
|
||||||
|
|
||||||
defineProps<{
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
contentHtml: string
|
contentHtml: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
useBodyScrollLock(() => props.open)
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -39,12 +39,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { X } from '@lucide/vue'
|
import { X } from '@lucide/vue'
|
||||||
|
|
||||||
defineProps<{
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
contentHtml: string
|
contentHtml: string
|
||||||
updatedLabel: string
|
updatedLabel: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
useBodyScrollLock(() => props.open)
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { Sparkles, X } from '@lucide/vue'
|
import { Sparkles, X } from '@lucide/vue'
|
||||||
|
|
||||||
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
|
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
|
||||||
@@ -7,6 +7,7 @@ import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
|
|||||||
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
|
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
|
||||||
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
|
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
|
||||||
import { useAdminReviewsManager } from './useAdminReviewsManager'
|
import { useAdminReviewsManager } from './useAdminReviewsManager'
|
||||||
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -55,20 +56,11 @@ function onKey(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
useBodyScrollLock(() => props.open)
|
||||||
() => props.open,
|
|
||||||
(open) => {
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.body.style.overflow = open ? 'hidden' : ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => window.addEventListener('keydown', onKey))
|
onMounted(() => window.addEventListener('keydown', onKey))
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('keydown', onKey)
|
window.removeEventListener('keydown', onKey)
|
||||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import type { AdminAuditEntry, AdminAuditQueryOptions } from '../../types/awards'
|
import type { AdminAuditEntry, AdminAuditQueryOptions } from '../../types/awards'
|
||||||
|
|
||||||
const auditPageLimit = 80
|
const auditPageLimit = 80
|
||||||
@@ -294,9 +295,9 @@ function getDateBoundary(value: string, isEndOfDay: boolean) {
|
|||||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRequestContextItems(entry: AdminAuditEntry) {
|
function buildRequestContextItems(entry: AdminAuditEntry, canViewAuditIp: boolean) {
|
||||||
return [
|
return [
|
||||||
{ key: 'IP', value: entry.createdFromIp || 'nicht erfasst' },
|
...(canViewAuditIp ? [{ key: 'IP', value: entry.createdFromIp || 'nicht erfasst' }] : []),
|
||||||
{ key: 'User-Agent', value: entry.userAgent || 'nicht erfasst' },
|
{ key: 'User-Agent', value: entry.userAgent || 'nicht erfasst' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -312,7 +313,7 @@ function buildRelatedLink(entry: AdminAuditEntry) {
|
|||||||
return route
|
return route
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAuditRow(entry: AdminAuditEntry): AuditLogRow {
|
function createAuditRow(entry: AdminAuditEntry, canViewAuditIp: boolean): AuditLogRow {
|
||||||
return {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
actionLabel: humanizeAction(entry.actionType),
|
actionLabel: humanizeAction(entry.actionType),
|
||||||
@@ -324,13 +325,13 @@ function createAuditRow(entry: AdminAuditEntry): AuditLogRow {
|
|||||||
ageLabel: formatAge(entry.createdAt),
|
ageLabel: formatAge(entry.createdAt),
|
||||||
metadataItems: parseMetadata(entry.metadataJson),
|
metadataItems: parseMetadata(entry.metadataJson),
|
||||||
changeItems: parseChangeItems(entry.metadataJson),
|
changeItems: parseChangeItems(entry.metadataJson),
|
||||||
requestContextItems: buildRequestContextItems(entry),
|
requestContextItems: buildRequestContextItems(entry, canViewAuditIp),
|
||||||
relatedLink: buildRelatedLink(entry),
|
relatedLink: buildRelatedLink(entry),
|
||||||
rawMetadataJson: entry.metadataJson || '{}',
|
rawMetadataJson: entry.metadataJson || '{}',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadCsv(entries: AdminAuditEntry[]) {
|
function downloadCsv(entries: AdminAuditEntry[], canViewAuditIp: boolean) {
|
||||||
const rows = [
|
const rows = [
|
||||||
[
|
[
|
||||||
'Id',
|
'Id',
|
||||||
@@ -340,7 +341,7 @@ function downloadCsv(entries: AdminAuditEntry[]) {
|
|||||||
'Objekt-Id',
|
'Objekt-Id',
|
||||||
'Zusammenfassung',
|
'Zusammenfassung',
|
||||||
'Zeitpunkt',
|
'Zeitpunkt',
|
||||||
'IP',
|
...(canViewAuditIp ? ['IP'] : []),
|
||||||
'User-Agent',
|
'User-Agent',
|
||||||
'Metadaten',
|
'Metadaten',
|
||||||
],
|
],
|
||||||
@@ -352,7 +353,7 @@ function downloadCsv(entries: AdminAuditEntry[]) {
|
|||||||
entry.entityId,
|
entry.entityId,
|
||||||
entry.summary,
|
entry.summary,
|
||||||
new Date(entry.createdAt).toISOString(),
|
new Date(entry.createdAt).toISOString(),
|
||||||
entry.createdFromIp,
|
...(canViewAuditIp ? [entry.createdFromIp ?? ''] : []),
|
||||||
entry.userAgent,
|
entry.userAgent,
|
||||||
entry.metadataJson,
|
entry.metadataJson,
|
||||||
]),
|
]),
|
||||||
@@ -372,6 +373,7 @@ function downloadCsv(entries: AdminAuditEntry[]) {
|
|||||||
|
|
||||||
export function useAdminAuditManager() {
|
export function useAdminAuditManager() {
|
||||||
const store = useAwardsStore()
|
const store = useAwardsStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const selectedAdmin = ref(allFilter)
|
const selectedAdmin = ref(allFilter)
|
||||||
const selectedAction = ref(allFilter)
|
const selectedAction = ref(allFilter)
|
||||||
@@ -405,9 +407,10 @@ export function useAdminAuditManager() {
|
|||||||
label: entityOptions.find((option) => option.value === item.key)?.label ?? item.key,
|
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 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 recentDayCount = computed(() => {
|
||||||
const minTimestamp = Date.now() - 24 * 60 * 60 * 1000
|
const minTimestamp = Date.now() - 24 * 60 * 60 * 1000
|
||||||
return auditEntries.value.filter((entry) => new Date(entry.createdAt).getTime() >= minTimestamp).length
|
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: 'Geladen', value: loadedCountLabel.value, note: `Page ${auditPageLimit}` },
|
||||||
{ label: 'Treffer', value: totalCountLabel.value, note: 'serverseitig gefiltert' },
|
{ label: 'Treffer', value: totalCountLabel.value, note: 'serverseitig gefiltert' },
|
||||||
{ label: '24h', value: recentDayCount.value.toLocaleString('de-DE'), note: 'neue Aktionen' },
|
{ 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 focusCards = computed<AuditFocusCard[]>(() => {
|
||||||
const topAdmin = adminCounts.value[0]
|
const topAdmin = adminCounts.value[0]
|
||||||
@@ -532,7 +535,7 @@ export function useAdminAuditManager() {
|
|||||||
|
|
||||||
function exportAuditCsv() {
|
function exportAuditCsv() {
|
||||||
if (auditEntries.value.length === 0) return
|
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.`
|
exportMessage.value = `CSV mit ${auditEntries.value.length.toLocaleString('de-DE')} geladenen Einträgen erstellt.`
|
||||||
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
|
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
|
||||||
exportMessageTimer = window.setTimeout(() => {
|
exportMessageTimer = window.setTimeout(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { LogOut, Shield, Trash2, X } from '@lucide/vue'
|
||||||
|
|
||||||
import type { AuthSession } from '../../types/awards'
|
import type { AuthSession } from '../../types/awards'
|
||||||
|
|
||||||
@@ -29,13 +30,14 @@ const isTeamPasswordSession = computed(() => Boolean(props.session?.teamLogin &&
|
|||||||
const isTeamSession = computed(() => Boolean(props.session?.teamLogin))
|
const isTeamSession = computed(() => Boolean(props.session?.teamLogin))
|
||||||
const isParticipantSession = computed(() => !isTeamSession.value)
|
const isParticipantSession = computed(() => !isTeamSession.value)
|
||||||
const profileHandle = computed(() => props.session?.teamLogin ?? props.session?.twitchUserId ?? props.twitchUser)
|
const profileHandle = computed(() => props.session?.teamLogin ?? props.session?.twitchUserId ?? props.twitchUser)
|
||||||
|
const displayInitial = computed(() => (props.session?.displayName ?? profileHandle.value).charAt(0).toUpperCase())
|
||||||
const showTeamLoginDetail = computed(() => Boolean(props.session?.teamLogin && !isTeamSession.value))
|
const showTeamLoginDetail = computed(() => Boolean(props.session?.teamLogin && !isTeamSession.value))
|
||||||
const statusLabel = computed(() => {
|
const authStatusLabel = computed(() => {
|
||||||
if (isTeamPasswordSession.value) return 'Angemeldet mit Team-Login'
|
if (isTeamPasswordSession.value) return 'Angemeldet mit Team-Login'
|
||||||
if (isTeamSession.value) return 'Admin-Login über Twitch'
|
if (isTeamSession.value) return 'Admin-Login über Twitch'
|
||||||
return 'Angemeldet über Twitch'
|
return 'Angemeldet über Twitch'
|
||||||
})
|
})
|
||||||
const statusText = computed(() => {
|
const authStatusText = computed(() => {
|
||||||
if (isTeamPasswordSession.value) return 'Diese Session läuft über deinen Team-Login.'
|
if (isTeamPasswordSession.value) return 'Diese Session läuft über deinen Team-Login.'
|
||||||
if (isTeamSession.value) return 'Diese Admin-Session nutzt deinen verknüpften Twitch-Account.'
|
if (isTeamSession.value) return 'Diese Admin-Session nutzt deinen verknüpften Twitch-Account.'
|
||||||
return 'Diese Session nutzt deinen Twitch-Account.'
|
return 'Diese Session nutzt deinen Twitch-Account.'
|
||||||
@@ -45,19 +47,34 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- ── Privacy Modal ─────────────────────────────────────── -->
|
||||||
<template v-if="props.privacyModalOpen">
|
<template v-if="props.privacyModalOpen">
|
||||||
<div class="home-modal-overlay" @click="props.onClosePrivacy" style="position:fixed;inset:0;z-index:400;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
|
<div
|
||||||
<div class="home-modal" @click="props.privacyModalStop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
class="fixed inset-0 z-[400] flex items-center justify-center p-6"
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
|
style="background:rgba(40,24,70,0.52);backdrop-filter:blur(6px);"
|
||||||
|
@click="props.onClosePrivacy"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex max-h-[88vh] w-full max-w-[680px] flex-col overflow-hidden rounded-3xl bg-white shadow-[0_40px_90px_rgba(40,24,70,0.4)]"
|
||||||
|
@click="props.privacyModalStop"
|
||||||
|
>
|
||||||
|
<div class="flex shrink-0 items-center justify-between gap-4 border-b border-[#f1ecfb] px-8 py-5">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Rechtliches</div>
|
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Rechtliches</p>
|
||||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">Datenschutzerklärung</h2>
|
<h2 class="font-['Cormorant_Garamond'] text-2xl font-bold text-[#3f3556]">Datenschutzerklärung</h2>
|
||||||
</div>
|
</div>
|
||||||
<button @click="props.onClosePrivacy" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;">✕</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[#f1ecfb] text-[#8b6cdb] transition hover:bg-[#e6dcf6]"
|
||||||
|
aria-label="Datenschutz schließen"
|
||||||
|
@click="props.onClosePrivacy"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
|
<div class="flex-1 overflow-y-auto px-8 py-7 text-sm leading-relaxed text-[#6f6685]">
|
||||||
<div v-if="props.privacyContentHtml" class="home-privacy-content" v-html="props.privacyContentHtml" />
|
<div v-if="props.privacyContentHtml" class="privacy-content" v-html="props.privacyContentHtml" />
|
||||||
<div v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;">
|
<div v-else class="rounded-2xl border border-[#f1ecfb] bg-[#fcfbff] px-4 py-3 text-sm">
|
||||||
Datenschutzerklärung wird geladen.
|
Datenschutzerklärung wird geladen.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,60 +82,150 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Account Modal ─────────────────────────────────────── -->
|
||||||
<template v-if="props.accountModalOpen">
|
<template v-if="props.accountModalOpen">
|
||||||
<div class="home-modal-overlay" @click="props.onCloseAccount" style="position:fixed;inset:0;z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
|
<div
|
||||||
<div class="home-modal" @click="props.accountModalStop" style="position:relative;width:100%;max-width:480px;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);overflow:hidden;">
|
class="fixed inset-0 z-[300] flex items-end justify-center p-4 sm:items-center sm:p-6"
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 28px 18px;border-bottom:1px solid #f1ecfb;">
|
style="background:rgba(40,24,70,0.52);backdrop-filter:blur(6px);"
|
||||||
<div>
|
@click="props.onCloseAccount"
|
||||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Mein Profil</div>
|
>
|
||||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:24px;margin:0;color:#3f3556;">@{{ profileHandle }}</h2>
|
<div
|
||||||
</div>
|
class="w-full max-w-[480px] overflow-hidden rounded-3xl bg-white shadow-[0_40px_90px_rgba(40,24,70,0.38)]"
|
||||||
<button @click="props.onCloseAccount" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;">✕</button>
|
@click="props.accountModalStop"
|
||||||
</div>
|
>
|
||||||
<div style="padding:24px 28px;display:flex;flex-direction:column;gap:16px;">
|
<!-- Header -->
|
||||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px;padding:14px 16px;border-radius:16px;background:#f6f1fd;border:1px solid #ede4fb;">
|
<div class="flex items-center justify-between gap-4 border-b border-[#f1ecfb] bg-gradient-to-br from-[#f7f2ff] to-[#fff7ed] px-7 py-5">
|
||||||
<svg v-if="isTeamPasswordSession" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 7a4 4 0 1 1-2.8-3.82"/><path d="M13 5 21 13"/><path d="M21 13 18 16"/><path d="M18 13 16 15"/></svg>
|
<div class="flex min-w-0 items-center gap-3.5">
|
||||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
|
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-gradient-to-br from-[#8b6cdb] to-[#7355c8] text-base font-bold text-white shadow-[0_6px_18px_rgba(124,86,196,0.3)]">
|
||||||
<span style="font-size:14px;color:#5f44ad;font-weight:800;">{{ statusLabel }}</span>
|
{{ displayInitial }}
|
||||||
<span style="grid-column:2;font-size:12.5px;color:#6f6685;line-height:1.45;">{{ statusText }}</span>
|
</div>
|
||||||
</div>
|
<div class="min-w-0">
|
||||||
<div style="padding:16px;border-radius:14px;background:#fafafa;border:1px solid #f0eafc;">
|
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Mein Profil</p>
|
||||||
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a99fc0;margin:0 0 10px;">Gespeicherte Daten</p>
|
<h2 class="truncate font-['Cormorant_Garamond'] text-2xl font-bold leading-tight text-[#3f3556]">
|
||||||
<div style="display:flex;flex-direction:column;gap:7px;">
|
@{{ profileHandle }}
|
||||||
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">{{ primaryIdentityLabel }}</span><span style="font-weight:600;color:#3f3556;">@{{ profileHandle }}</span></div>
|
</h2>
|
||||||
<div v-if="showTeamLoginDetail" class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Team-Login</span><span style="font-weight:600;color:#3f3556;">@{{ props.session?.teamLogin }}</span></div>
|
|
||||||
<div v-if="props.session?.boundTwitchUserId" class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Gebundenes Twitch</span><span style="font-weight:600;color:#3f3556;">@{{ props.session.boundTwitchUserId }}</span></div>
|
|
||||||
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Rolle</span><span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ props.role }}</span></div>
|
|
||||||
<div v-if="isParticipantSession" class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Einreichungen</span><span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</span></div>
|
|
||||||
<div v-if="isParticipantSession" class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Löschfrist</span><span style="font-weight:600;color:#059669;">März 2027 (auto.)</span></div>
|
|
||||||
<div v-else class="home-account-data-row" style="display:flex;justify-content:space-between;gap:16px;font-size:13.5px;"><span style="color:#6f6685;">Accountverwaltung</span><span style="font-weight:600;color:#3f3556;text-align:right;">Team-Accounts bleiben bestehen und werden im Admin-Panel verwaltet.</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="props.onOpenPrivacy" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #ede4fb;background:#f9f6ff;color:#6a4fb8;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#f1ecfb;">
|
<button
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
type="button"
|
||||||
Datenschutzerklärung lesen
|
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[#f1ecfb] text-[#8b6cdb] transition hover:bg-[#e6dcf6]"
|
||||||
|
aria-label="Profil schließen"
|
||||||
|
@click="props.onCloseAccount"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button @click="props.onLogout" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #e2e8f0;background:#f8fafc;color:#64748b;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#f1f5f9;">
|
</div>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
|
||||||
{{ logoutLabel }}
|
<!-- Scrollable body -->
|
||||||
</button>
|
<div class="max-h-[72vh] overflow-y-auto">
|
||||||
<template v-if="isParticipantSession && props.deleteNotConfirm">
|
<div class="space-y-3 p-6">
|
||||||
<button @click="props.onRequestDelete" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #fecdd3;background:#fff5f5;color:#e11d48;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#ffe4e6;">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
<!-- Auth status -->
|
||||||
Meine Daten löschen (Löschrecht Art. 17 DSGVO)
|
<div class="grid grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-2xl border border-[#ede4fb] bg-[#f6f1fd] px-4 py-3.5">
|
||||||
</button>
|
<div class="mt-0.5 shrink-0">
|
||||||
</template>
|
<svg v-if="isTeamPasswordSession" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<template v-if="isParticipantSession && props.deleteConfirm">
|
<path d="M15 7a4 4 0 1 1-2.8-3.82"/><path d="M13 5 21 13"/><path d="M21 13 18 16"/><path d="M18 13 16 15"/>
|
||||||
<div style="padding:16px;border-radius:14px;background:#fff5f5;border:1.5px solid #fecdd3;">
|
</svg>
|
||||||
<p style="font-size:13.5px;color:#9f1239;font-weight:600;margin:0 0 6px;">Alle deine Daten werden sofort gelöscht.</p>
|
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="#9146FF" aria-hidden="true">
|
||||||
<p style="font-size:12.5px;color:#e11d48;margin:0 0 14px;line-height:1.5;">Das umfasst deine Votes, Nominierungen und Clip-Einreichungen. Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/>
|
||||||
<p v-if="props.accountActionError" style="font-size:12.5px;color:#be123c;margin:0 0 12px;line-height:1.5;font-weight:600;">{{ props.accountActionError }}</p>
|
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/>
|
||||||
<div class="home-account-confirm-actions" style="display:flex;gap:8px;">
|
</svg>
|
||||||
<button :disabled="props.authLoading" @click="props.onCancelDelete" style="flex:1;padding:10px;border-radius:10px;border:1px solid #e6dcf6;background:white;color:#6f6685;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;cursor:pointer;" style-hover="background:#f9f6ff;">Abbrechen</button>
|
</div>
|
||||||
<button :disabled="props.authLoading" @click="props.onConfirmDelete" style="flex:1;padding:10px;border-radius:10px;border:none;background:#e11d48;color:white;font-family:'Outfit',sans-serif;font-weight:700;font-size:13px;cursor:pointer;" style-hover="opacity:0.9;">{{ props.authLoading ? 'Löscht …' : 'Jetzt löschen' }}</button>
|
<span class="text-sm font-extrabold text-[#5f44ad]">{{ authStatusLabel }}</span>
|
||||||
|
<span class="col-start-2 text-xs leading-relaxed text-[#6f6685]">{{ authStatusText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saved data -->
|
||||||
|
<div class="rounded-2xl border border-[#ede4fb] bg-[#fafafa] px-4 py-3.5">
|
||||||
|
<p class="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#a99fc0]">Gespeicherte Daten</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-[#6f6685]">{{ primaryIdentityLabel }}</span>
|
||||||
|
<span class="font-semibold text-[#3f3556]">@{{ profileHandle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-[#6f6685]">Rolle</span>
|
||||||
|
<span class="text-[10px] font-bold uppercase tracking-[0.1em] text-[#8b6cdb]">{{ props.role }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showTeamLoginDetail" class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-[#6f6685]">Team-Login</span>
|
||||||
|
<span class="font-semibold text-[#3f3556]">@{{ props.session?.teamLogin }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.session?.boundTwitchUserId" class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-[#6f6685]">Gebundenes Twitch</span>
|
||||||
|
<span class="font-semibold text-[#3f3556]">@{{ props.session.boundTwitchUserId }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="isParticipantSession">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-[#6f6685]">Einreichungen</span>
|
||||||
|
<span class="font-semibold text-[#3f3556]">Clips, Votes, Nominierungen</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-start justify-between gap-4 text-sm">
|
||||||
|
<span class="shrink-0 text-[#6f6685]">Accountverwaltung</span>
|
||||||
|
<span class="text-right font-semibold text-[#3f3556]">Team-Accounts werden im Admin-Panel verwaltet.</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
<!-- Privacy -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded-xl border border-[#ede4fb] bg-[#f9f6ff] px-4 py-3 text-left text-sm font-semibold text-[#6a4fb8] transition hover:bg-[#f1ecfb]"
|
||||||
|
@click="props.onOpenPrivacy"
|
||||||
|
>
|
||||||
|
<Shield class="h-4 w-4 shrink-0" />
|
||||||
|
Datenschutzerklärung lesen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left text-sm font-semibold text-slate-500 transition hover:bg-slate-100"
|
||||||
|
@click="props.onLogout"
|
||||||
|
>
|
||||||
|
<LogOut class="h-4 w-4 shrink-0" />
|
||||||
|
{{ logoutLabel }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete request -->
|
||||||
|
<template v-if="isParticipantSession && props.deleteNotConfirm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-left text-sm font-semibold text-rose-600 transition hover:bg-rose-100"
|
||||||
|
@click="props.onRequestDelete"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 shrink-0" />
|
||||||
|
Meine Daten löschen (Löschrecht Art. 17 DSGVO)
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<template v-if="isParticipantSession && props.deleteConfirm">
|
||||||
|
<div class="rounded-2xl border-[1.5px] border-rose-200 bg-rose-50 p-4">
|
||||||
|
<p class="text-sm font-semibold text-rose-800">Alle deine Daten werden sofort gelöscht.</p>
|
||||||
|
<p class="mt-1 text-xs leading-relaxed text-rose-600">Das umfasst Votes, Nominierungen und Clip-Einreichungen. Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||||
|
<p v-if="props.accountActionError" class="mt-2 text-xs font-bold text-rose-700">{{ props.accountActionError }}</p>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
:disabled="props.authLoading"
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-xl border border-[#e6dcf6] bg-white py-2.5 text-sm font-semibold text-[#6f6685] transition hover:bg-[#f9f6ff] disabled:opacity-50"
|
||||||
|
@click="props.onCancelDelete"
|
||||||
|
>Abbrechen</button>
|
||||||
|
<button
|
||||||
|
:disabled="props.authLoading"
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-xl bg-rose-600 py-2.5 text-sm font-bold text-white transition hover:bg-rose-700 disabled:opacity-50"
|
||||||
|
@click="props.onConfirmDelete"
|
||||||
|
>{{ props.authLoading ? 'Löscht …' : 'Jetzt löschen' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,23 +233,20 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.home-privacy-content :deep(p) {
|
.privacy-content :deep(p) {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
}
|
}
|
||||||
|
.privacy-content :deep(p:last-child),
|
||||||
.home-privacy-content :deep(p:last-child),
|
.privacy-content :deep(ul:last-child),
|
||||||
.home-privacy-content :deep(ul:last-child),
|
.privacy-content :deep(ol:last-child) {
|
||||||
.home-privacy-content :deep(ol:last-child) {
|
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
.privacy-content :deep(ul),
|
||||||
.home-privacy-content :deep(ul),
|
.privacy-content :deep(ol) {
|
||||||
.home-privacy-content :deep(ol) {
|
|
||||||
margin: 0 0 14px 20px;
|
margin: 0 0 14px 20px;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
.privacy-content :deep(li) {
|
||||||
.home-privacy-content :deep(li) {
|
|
||||||
margin: 3px 0;
|
margin: 3px 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { X } from '@lucide/vue'
|
import { X } from '@lucide/vue'
|
||||||
|
|
||||||
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
title?: string
|
title?: string
|
||||||
@@ -23,19 +25,11 @@ function onKey(event: KeyboardEvent) {
|
|||||||
if (event.key === 'Escape' && props.open) emit('close')
|
if (event.key === 'Escape' && props.open) emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
useBodyScrollLock(() => props.open)
|
||||||
() => props.open,
|
|
||||||
(open) => {
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.body.style.overflow = open ? 'hidden' : ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => window.addEventListener('keydown', onKey))
|
onMounted(() => window.addEventListener('keydown', onKey))
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('keydown', onKey)
|
window.removeEventListener('keydown', onKey)
|
||||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { onBeforeUnmount, watch, type WatchSource } from 'vue'
|
||||||
|
|
||||||
|
let lockCount = 0
|
||||||
|
let previousBodyOverflow = ''
|
||||||
|
let previousBodyOverscrollBehavior = ''
|
||||||
|
let previousBodyPaddingRight = ''
|
||||||
|
let previousHtmlOverflow = ''
|
||||||
|
let previousHtmlOverscrollBehavior = ''
|
||||||
|
|
||||||
|
function acquireBodyScrollLock() {
|
||||||
|
if (typeof document === 'undefined' || typeof window === 'undefined') return
|
||||||
|
|
||||||
|
if (lockCount === 0) {
|
||||||
|
previousBodyOverflow = document.body.style.overflow
|
||||||
|
previousBodyOverscrollBehavior = document.body.style.overscrollBehavior
|
||||||
|
previousBodyPaddingRight = document.body.style.paddingRight
|
||||||
|
previousHtmlOverflow = document.documentElement.style.overflow
|
||||||
|
previousHtmlOverscrollBehavior = document.documentElement.style.overscrollBehavior
|
||||||
|
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
document.body.style.paddingRight = `${scrollbarWidth}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.body.style.overscrollBehavior = 'contain'
|
||||||
|
document.documentElement.style.overflow = 'hidden'
|
||||||
|
document.documentElement.style.overscrollBehavior = 'contain'
|
||||||
|
}
|
||||||
|
|
||||||
|
lockCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseBodyScrollLock() {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (lockCount === 0) return
|
||||||
|
|
||||||
|
lockCount -= 1
|
||||||
|
if (lockCount > 0) return
|
||||||
|
|
||||||
|
document.body.style.overflow = previousBodyOverflow
|
||||||
|
document.body.style.overscrollBehavior = previousBodyOverscrollBehavior
|
||||||
|
document.body.style.paddingRight = previousBodyPaddingRight
|
||||||
|
document.documentElement.style.overflow = previousHtmlOverflow
|
||||||
|
document.documentElement.style.overscrollBehavior = previousHtmlOverscrollBehavior
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBodyScrollLock(locked: WatchSource<boolean>) {
|
||||||
|
let active = false
|
||||||
|
|
||||||
|
const stop = watch(
|
||||||
|
locked,
|
||||||
|
(shouldLock) => {
|
||||||
|
if (shouldLock && !active) {
|
||||||
|
active = true
|
||||||
|
acquireBodyScrollLock()
|
||||||
|
} else if (!shouldLock && active) {
|
||||||
|
active = false
|
||||||
|
releaseBodyScrollLock()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stop()
|
||||||
|
if (active) {
|
||||||
|
active = false
|
||||||
|
releaseBodyScrollLock()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ export interface AdminAuditEntry {
|
|||||||
summary: string
|
summary: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
metadataJson: string
|
metadataJson: string
|
||||||
createdFromIp: string
|
createdFromIp: string | null
|
||||||
userAgent: string
|
userAgent: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const passwordForm = reactive({
|
|||||||
const loginMode = ref<'password' | 'twitch'>('password')
|
const loginMode = ref<'password' | 'twitch'>('password')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
|
const loginFormElement = ref<HTMLFormElement | null>(null)
|
||||||
|
const passwordChangeFormElement = ref<HTMLFormElement | null>(null)
|
||||||
|
|
||||||
const passwordChangeRequired = computed(() => Boolean(authStore.session?.mustChangePassword))
|
const passwordChangeRequired = computed(() => Boolean(authStore.session?.mustChangePassword))
|
||||||
|
|
||||||
@@ -40,6 +42,10 @@ const redirectTarget = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function submitLogin() {
|
async function submitLogin() {
|
||||||
|
if (authStore.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
successMessage.value = ''
|
successMessage.value = ''
|
||||||
|
|
||||||
@@ -81,6 +87,10 @@ async function submitLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startTwitchLogin() {
|
async function startTwitchLogin() {
|
||||||
|
if (authStore.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
successMessage.value = ''
|
successMessage.value = ''
|
||||||
|
|
||||||
@@ -97,6 +107,10 @@ async function startTwitchLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitPasswordChange() {
|
async function submitPasswordChange() {
|
||||||
|
if (authStore.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
successMessage.value = ''
|
successMessage.value = ''
|
||||||
|
|
||||||
@@ -168,7 +182,7 @@ async function submitPasswordChange() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-if="!passwordChangeRequired && loginMode === 'password'" class="login-form" @submit.prevent="submitLogin">
|
<form ref="loginFormElement" v-if="!passwordChangeRequired && loginMode === 'password'" class="login-form" @submit.prevent="submitLogin">
|
||||||
<label>
|
<label>
|
||||||
<span>Login</span>
|
<span>Login</span>
|
||||||
<input
|
<input
|
||||||
@@ -177,6 +191,7 @@ async function submitPasswordChange() {
|
|||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
required
|
required
|
||||||
placeholder="E-Mail oder Username"
|
placeholder="E-Mail oder Username"
|
||||||
|
@keydown.enter.exact.prevent="loginFormElement?.requestSubmit()"
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -188,6 +203,7 @@ async function submitPasswordChange() {
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
placeholder="Team-Passwort eingeben"
|
placeholder="Team-Passwort eingeben"
|
||||||
|
@keydown.enter.exact.prevent="loginFormElement?.requestSubmit()"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -225,7 +241,7 @@ async function submitPasswordChange() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form v-else class="login-form" @submit.prevent="submitPasswordChange">
|
<form ref="passwordChangeFormElement" v-else class="login-form" @submit.prevent="submitPasswordChange">
|
||||||
<label>
|
<label>
|
||||||
<span>Aktuelles Passwort</span>
|
<span>Aktuelles Passwort</span>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
@@ -234,6 +250,7 @@ async function submitPasswordChange() {
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
placeholder="Temporäres Passwort"
|
placeholder="Temporäres Passwort"
|
||||||
|
@keydown.enter.exact.prevent="passwordChangeFormElement?.requestSubmit()"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -246,6 +263,7 @@ async function submitPasswordChange() {
|
|||||||
required
|
required
|
||||||
minlength="10"
|
minlength="10"
|
||||||
placeholder="Mindestens 10 Zeichen"
|
placeholder="Mindestens 10 Zeichen"
|
||||||
|
@keydown.enter.exact.prevent="passwordChangeFormElement?.requestSubmit()"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -258,6 +276,7 @@ async function submitPasswordChange() {
|
|||||||
required
|
required
|
||||||
minlength="10"
|
minlength="10"
|
||||||
placeholder="Neues Passwort bestätigen"
|
placeholder="Neues Passwort bestätigen"
|
||||||
|
@keydown.enter.exact.prevent="passwordChangeFormElement?.requestSubmit()"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
|
|
||||||
import AdminToastViewport from '../../components/admin/AdminToastViewport.vue'
|
import AdminToastViewport from '../../components/admin/AdminToastViewport.vue'
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
import { getRiskMetricValue } from '../../lib/adminMetrics'
|
import { getRiskMetricValue } from '../../lib/adminMetrics'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
@@ -34,6 +35,8 @@ const adminWorkspaceLoading = ref(false)
|
|||||||
const adminWorkspaceLoaded = ref(false)
|
const adminWorkspaceLoaded = ref(false)
|
||||||
const mobileNavOpen = ref(false)
|
const mobileNavOpen = ref(false)
|
||||||
|
|
||||||
|
useBodyScrollLock(() => mobileNavOpen.value)
|
||||||
|
|
||||||
const fullNavGroups = computed(() => [
|
const fullNavGroups = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Betrieb',
|
label: 'Betrieb',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModa
|
|||||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import {
|
import {
|
||||||
createSeasonTimelineRows,
|
createSeasonTimelineRows,
|
||||||
|
hasShowDayPassed,
|
||||||
normalizePhaseKey,
|
normalizePhaseKey,
|
||||||
resolveAutoPhase,
|
resolveAutoPhase,
|
||||||
SEASON_PHASES,
|
SEASON_PHASES,
|
||||||
@@ -32,6 +33,7 @@ import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonMana
|
|||||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||||
import Button from '../../components/ui/Button.vue'
|
import Button from '../../components/ui/Button.vue'
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import Modal from '../../components/ui/Modal.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
store,
|
store,
|
||||||
@@ -88,10 +90,20 @@ const currentPhaseKey = computed(() => normalizePhaseKey(form.currentPhase))
|
|||||||
const autoPhase = computed(() => resolveAutoPhase(form))
|
const autoPhase = computed(() => resolveAutoPhase(form))
|
||||||
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
|
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
|
||||||
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
|
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 editingPhase = ref<PhaseKey | null>(null)
|
||||||
const timelineError = ref('')
|
const timelineError = ref('')
|
||||||
const draft = reactive({ start: '', end: '', showStartsAt: '20:00' })
|
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(() =>
|
const timelineRows = computed(() =>
|
||||||
createSeasonTimelineRows(form, currentPhaseKey.value, autoPhase.value ?? '', editingPhase.value).map((row) => ({
|
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`
|
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 ----------
|
// ---------- Quick stats ----------
|
||||||
const quickStats = computed(() => {
|
const quickStats = computed(() => {
|
||||||
const d = seasonDetail.value
|
const d = seasonDetail.value
|
||||||
@@ -290,7 +414,7 @@ const readinessScore = computed(() => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="gap-1.5 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100"
|
class="gap-1.5 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100"
|
||||||
:disabled="completing || !canCompleteSelectedSeason"
|
:disabled="completing || !canCompleteSelectedSeason"
|
||||||
@click="completeSeason"
|
@click="requestCompleteSeason"
|
||||||
>
|
>
|
||||||
<CheckCircle2 class="h-4 w-4" /> {{ completing ? 'Schließt …' : 'Beenden' }}
|
<CheckCircle2 class="h-4 w-4" /> {{ completing ? 'Schließt …' : 'Beenden' }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -467,7 +591,7 @@ const readinessScore = computed(() => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
class="ml-2 border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
|
class="ml-2 border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
|
||||||
:disabled="saving || !selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
|
:disabled="saving || !selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
|
||||||
@click="autoPhase && !autoPhaseIsCompleted && activatePhase(autoPhase)"
|
@click="autoPhase && !autoPhaseIsCompleted && requestPhaseActivationByTitle(autoPhase)"
|
||||||
>
|
>
|
||||||
{{ autoPhaseIsCompleted ? 'Über Beenden' : 'Aktivieren' }}
|
{{ autoPhaseIsCompleted ? 'Über Beenden' : 'Aktivieren' }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -558,8 +682,8 @@ const readinessScore = computed(() => {
|
|||||||
: row.finalLocked
|
: row.finalLocked
|
||||||
? 'border border-slate-200 bg-slate-50 text-slate-500'
|
? 'border border-slate-200 bg-slate-50 text-slate-500'
|
||||||
: 'border border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
|
: 'border border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
|
||||||
:disabled="saving || !selectedSeasonId || row.active || row.finalLocked"
|
:disabled="saving || !selectedSeasonId || row.active || row.finalLocked"
|
||||||
@click="activatePhase(row.title)"
|
@click="requestPhaseActivation(row)"
|
||||||
>
|
>
|
||||||
<CheckCircle2 v-if="row.active" class="h-3.5 w-3.5" />
|
<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" />
|
<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-close="() => { seasonToDelete = null }"
|
||||||
:on-confirm-delete="confirmDeleteSeason"
|
: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>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, reactive, ref } from 'vue'
|
|||||||
|
|
||||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
|
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
|
||||||
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||||
import Button from '../../components/ui/Button.vue'
|
import Button from '../../components/ui/Button.vue'
|
||||||
import Card from '../../components/ui/Card.vue'
|
import Card from '../../components/ui/Card.vue'
|
||||||
@@ -67,6 +68,15 @@ const memberPendingDeletion = computed(() =>
|
|||||||
members.value.find((member) => member.id === confirmDeleteMemberId.value) ?? null,
|
members.value.find((member) => member.id === confirmDeleteMemberId.value) ?? null,
|
||||||
)
|
)
|
||||||
const onlineMembers = computed(() => members.value.filter((member) => member.isOnline).length)
|
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(() => [
|
const summaryItems = computed(() => [
|
||||||
{ label: 'Mitglieder', value: members.value.length, note: `${onlineMembers.value} online` },
|
{ label: 'Mitglieder', value: members.value.length, note: `${onlineMembers.value} online` },
|
||||||
|
|||||||
Reference in New Issue
Block a user