Add team roles and content management updates
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { FileText } from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
|
||||
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
|
||||
import AdminContentFooterPreviewModal from '../../components/admin/AdminContentFooterPreviewModal.vue'
|
||||
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
|
||||
import type { FooterPreviewKey } from '../../components/admin/AdminContentLinksSection.vue'
|
||||
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
|
||||
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
|
||||
import AdminContentSectionNav from '../../components/admin/AdminContentSectionNav.vue'
|
||||
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
|
||||
|
||||
const sectionLinks = [
|
||||
@@ -26,7 +30,7 @@ const {
|
||||
saveError,
|
||||
privacyPreviewOpen,
|
||||
iconUploadError,
|
||||
privacyPreviewBlocks,
|
||||
privacyPreviewHtml,
|
||||
privacyUpdatedLabel,
|
||||
addSocialLink,
|
||||
removeSocialLink,
|
||||
@@ -44,6 +48,35 @@ const {
|
||||
saveSiteSettings,
|
||||
adminSiteSettings,
|
||||
} = useAdminContentManager()
|
||||
|
||||
const footerPreviewOpen = ref(false)
|
||||
const footerPreviewKey = ref<FooterPreviewKey>('imprint')
|
||||
|
||||
const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, url: string, content: string }>>(() => ({
|
||||
imprint: {
|
||||
title: 'Impressum',
|
||||
url: form.imprintUrl,
|
||||
content: form.imprintContent,
|
||||
},
|
||||
contact: {
|
||||
title: 'Kontakt',
|
||||
url: form.contactUrl,
|
||||
content: form.contactContent,
|
||||
},
|
||||
sponsors: {
|
||||
title: 'Sponsoren & Partner',
|
||||
url: form.sponsorsUrl,
|
||||
content: form.sponsorsContent,
|
||||
},
|
||||
}))
|
||||
|
||||
const activeFooterPreview = computed(() => footerPreviewPages.value[footerPreviewKey.value])
|
||||
const activeFooterPreviewHtml = computed(() => privacyContentToHtml(activeFooterPreview.value.content))
|
||||
|
||||
function openFooterPreview(key: FooterPreviewKey) {
|
||||
footerPreviewKey.value = key
|
||||
footerPreviewOpen.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,7 +99,12 @@ const {
|
||||
|
||||
<div class="space-y-6">
|
||||
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
|
||||
<AdminContentLinksSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
|
||||
<AdminContentLinksSection
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:save-site-settings="saveSiteSettings"
|
||||
@open-preview="openFooterPreview"
|
||||
/>
|
||||
<AdminContentSocialLinksSection
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
@@ -104,9 +142,16 @@ const {
|
||||
|
||||
<AdminContentPrivacyPreviewModal
|
||||
:open="privacyPreviewOpen"
|
||||
:blocks="privacyPreviewBlocks"
|
||||
:content-html="privacyPreviewHtml"
|
||||
:updated-label="privacyUpdatedLabel"
|
||||
@close="privacyPreviewOpen = false"
|
||||
/>
|
||||
<AdminContentFooterPreviewModal
|
||||
:open="footerPreviewOpen"
|
||||
:title="activeFooterPreview.title"
|
||||
:url="activeFooterPreview.url"
|
||||
:content-html="activeFooterPreviewHtml"
|
||||
@close="footerPreviewOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Settings,
|
||||
Tags,
|
||||
Trophy,
|
||||
UserCog,
|
||||
Users,
|
||||
FileText,
|
||||
} from '@lucide/vue'
|
||||
@@ -33,47 +34,43 @@ const fullNavGroups = computed(() => [
|
||||
{
|
||||
label: 'Betrieb',
|
||||
items: [
|
||||
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, badge: () => null },
|
||||
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, permission: 'dashboard', badge: () => null },
|
||||
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, permission: 'nominations', badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Inhalte',
|
||||
items: [
|
||||
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` },
|
||||
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` },
|
||||
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
|
||||
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, badge: () => `${pendingClipCount.value}` },
|
||||
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, badge: () => null },
|
||||
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
|
||||
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
|
||||
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, permission: 'candidates', badge: () => `${store.adminSeasonDetail.candidates.length}` },
|
||||
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, permission: 'clips', badge: () => `${pendingClipCount.value}` },
|
||||
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, permission: 'content', badge: () => null },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Kontrolle',
|
||||
items: [
|
||||
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, badge: () => `${openRiskCount.value}` },
|
||||
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, badge: () => null },
|
||||
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, permission: 'risk', badge: () => `${openRiskCount.value}` },
|
||||
{ label: 'Team', to: '/admin/team', description: 'Logins und Rollen', icon: UserCog, permission: 'team', badge: () => null },
|
||||
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, permission: 'audit', badge: () => null },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Auswertung',
|
||||
items: [
|
||||
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, badge: () => null },
|
||||
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, badge: () => `${store.adminSeasonDetail.results.length}` },
|
||||
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, badge: () => null },
|
||||
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, permission: 'analytics', badge: () => null },
|
||||
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, permission: 'winners', badge: () => `${store.adminSeasonDetail.results.length}` },
|
||||
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, permission: 'settings', badge: () => null },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const navGroups = computed(() => {
|
||||
if (authStore.canManageAdminWorkspace) {
|
||||
return fullNavGroups.value
|
||||
}
|
||||
|
||||
const allowedRoutes = new Set(['/admin/content', '/admin/settings'])
|
||||
return fullNavGroups.value
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => allowedRoutes.has(item.to)),
|
||||
items: group.items.filter((item) => !item.permission || authStore.hasPermission(item.permission)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
@@ -97,10 +94,10 @@ async function ensureAdminWorkspace() {
|
||||
try {
|
||||
if (authStore.canManageAdminWorkspace) {
|
||||
await store.initializeAdminWorkspace()
|
||||
} else {
|
||||
} else if (authStore.hasPermission('content') || authStore.hasPermission('settings')) {
|
||||
await store.loadAdminContentWorkspace()
|
||||
}
|
||||
adminWorkspaceLoaded.value = store.apiMode === 'api'
|
||||
adminWorkspaceLoaded.value = true
|
||||
} finally {
|
||||
adminWorkspaceLoading.value = false
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const filteredNominations = computed(() => {
|
||||
(statusFilter.value === 'selected' && !!categoryFilter.value) ||
|
||||
(statusFilter.value === 'empty-category' && candidateCount === 0) ||
|
||||
(statusFilter.value === 'heavy' && heavyCategoryIds.has(nomination.categoryId))
|
||||
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
|
||||
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId, nomination.streamUrl]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
@@ -176,7 +176,7 @@ watch(
|
||||
<input
|
||||
v-model="query"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Nach Kandidat, User oder Kategorie suchen"
|
||||
placeholder="Nach Link, User oder Kategorie suchen"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
@@ -206,7 +206,7 @@ watch(
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-1 truncate text-lg font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
|
||||
<h3 class="mt-1 truncate text-lg font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
|
||||
@@ -38,6 +38,7 @@ const {
|
||||
latestSeasonAuditMeta,
|
||||
canCreate,
|
||||
activatePhase,
|
||||
activatePublicSeason,
|
||||
openCreateModal,
|
||||
saveSeason,
|
||||
completeSeason,
|
||||
@@ -83,6 +84,7 @@ const {
|
||||
:can-complete-selected-season="canCompleteSelectedSeason"
|
||||
:selected-season-is-current="selectedSeason?.isCurrent ?? false"
|
||||
:open-delete-season-modal="openDeleteSeasonModal"
|
||||
:activate-public-season="activatePublicSeason"
|
||||
:save-season="saveSeason"
|
||||
:complete-season="completeSeason"
|
||||
/>
|
||||
|
||||
@@ -37,8 +37,14 @@ const {
|
||||
demoPasswordSet,
|
||||
demoManagedByDatabase,
|
||||
demoPasswordInput,
|
||||
twitchClientSecretSet,
|
||||
twitchAuthConfigured,
|
||||
twitchAuthManagedByDatabase,
|
||||
twitchClientSecretInput,
|
||||
demoPasswordHint,
|
||||
twitchSecretHint,
|
||||
demoCredentialsComplete,
|
||||
twitchAuthComplete,
|
||||
operationalSummary,
|
||||
hasUnsavedOperationalChanges,
|
||||
saveOperationalSettings,
|
||||
@@ -82,6 +88,7 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
||||
|
||||
<AdminOperationalSettingsCard
|
||||
v-model:demo-password="demoPasswordInput"
|
||||
v-model:twitch-client-secret="twitchClientSecretInput"
|
||||
:form="operationalForm"
|
||||
:loading="operationalLoading"
|
||||
:saving="operationalSaving"
|
||||
@@ -91,6 +98,11 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
||||
:demo-password-set="demoPasswordSet"
|
||||
:demo-managed-by-database="demoManagedByDatabase"
|
||||
:demo-credentials-complete="demoCredentialsComplete"
|
||||
:twitch-secret-hint="twitchSecretHint"
|
||||
:twitch-client-secret-set="twitchClientSecretSet"
|
||||
:twitch-auth-configured="twitchAuthConfigured"
|
||||
:twitch-auth-managed-by-database="twitchAuthManagedByDatabase"
|
||||
:twitch-auth-complete="twitchAuthComplete"
|
||||
:summary="operationalSummary"
|
||||
:dirty="hasUnsavedOperationalChanges"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound, LockKeyhole, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import PasswordField from '../../components/ui/PasswordField.vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { AdminTeamMember } from '../../types/awards'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canResetPasswords = computed(() => authStore.isOwnerOrCreator)
|
||||
const canDeleteMembers = computed(() => authStore.isOwnerOrCreator)
|
||||
const canEditMemberLogin = computed(() => authStore.isOwnerOrCreator)
|
||||
const canChangeOwnPassword = computed(() => authStore.isOwnerOrCreator && authStore.isTeamSession)
|
||||
const passwordModalOpen = ref(false)
|
||||
const createMemberModalOpen = ref(false)
|
||||
const editMemberModalOpen = ref(false)
|
||||
const profileError = ref('')
|
||||
const profileSuccess = ref('')
|
||||
const ownPasswordForm = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const {
|
||||
loading,
|
||||
saving,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
generatedPassword,
|
||||
generatedPasswordLogin,
|
||||
confirmDeleteMemberId,
|
||||
members,
|
||||
roles,
|
||||
permissions,
|
||||
memberForm,
|
||||
selectedMember,
|
||||
activeMembers,
|
||||
pendingPasswordChanges,
|
||||
roleOptions,
|
||||
hasRoleChanges,
|
||||
startCreateMember,
|
||||
startEditMember,
|
||||
saveMember,
|
||||
resetMemberPassword,
|
||||
requestDeleteMember,
|
||||
cancelDeleteMember,
|
||||
deleteMember,
|
||||
roleHasPermission,
|
||||
setRolePermission,
|
||||
saveRolePermissions,
|
||||
} = useAdminTeamManager()
|
||||
|
||||
const memberPendingDeletion = computed(() =>
|
||||
members.value.find((member) => member.id === confirmDeleteMemberId.value) ?? null,
|
||||
)
|
||||
|
||||
const summaryItems = computed(() => [
|
||||
{ label: 'Mitglieder', value: members.value.length, note: `${activeMembers.value} aktiv` },
|
||||
{ label: 'Rollen', value: roles.value.length, note: 'Owner, Creator und Team' },
|
||||
{ label: 'PW-Wechsel', value: pendingPasswordChanges.value, note: 'temporäre Passwörter offen' },
|
||||
])
|
||||
|
||||
function roleLabel(roleKey: string) {
|
||||
return roles.value.find((role) => role.key === roleKey)?.label ?? roleKey
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return 'Nie'
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function memberStatus(member: AdminTeamMember) {
|
||||
if (!member.isActive) return 'Inaktiv'
|
||||
return member.mustChangePassword ? 'PW-Wechsel offen' : 'Aktiv'
|
||||
}
|
||||
|
||||
function isOwnTeamAccount(member: AdminTeamMember) {
|
||||
const teamLogin = authStore.session?.teamLogin?.toLowerCase()
|
||||
const boundTwitchUserId = authStore.session?.boundTwitchUserId?.toLowerCase()
|
||||
return Boolean(teamLogin && member.login.toLowerCase() === teamLogin)
|
||||
|| Boolean(boundTwitchUserId && member.boundTwitchUserId?.toLowerCase() === boundTwitchUserId)
|
||||
}
|
||||
|
||||
function canDeleteMember(member: AdminTeamMember) {
|
||||
if (!canDeleteMembers.value) return false
|
||||
if (member.role === 'creator') {
|
||||
return isOwnTeamAccount(member)
|
||||
}
|
||||
if (member.role === 'owner') {
|
||||
return authStore.isCreator || isOwnTeamAccount(member)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handlePermissionChange(roleKey: string, permissionKey: string, event: Event) {
|
||||
setRolePermission(roleKey, permissionKey, event.target instanceof HTMLInputElement && event.target.checked)
|
||||
}
|
||||
|
||||
function openCreateMemberModal() {
|
||||
startCreateMember()
|
||||
createMemberModalOpen.value = true
|
||||
editMemberModalOpen.value = false
|
||||
}
|
||||
|
||||
function openEditMemberModal(member: AdminTeamMember) {
|
||||
startEditMember(member)
|
||||
editMemberModalOpen.value = true
|
||||
createMemberModalOpen.value = false
|
||||
}
|
||||
|
||||
function closeMemberModals() {
|
||||
createMemberModalOpen.value = false
|
||||
editMemberModalOpen.value = false
|
||||
startCreateMember()
|
||||
}
|
||||
|
||||
async function submitMemberForm() {
|
||||
const saved = await saveMember()
|
||||
if (saved) {
|
||||
createMemberModalOpen.value = false
|
||||
editMemberModalOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteMemberModal(member: AdminTeamMember) {
|
||||
requestDeleteMember(member)
|
||||
}
|
||||
|
||||
async function confirmDeleteMember() {
|
||||
if (!memberPendingDeletion.value) return
|
||||
await deleteMember(memberPendingDeletion.value)
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
ownPasswordForm.currentPassword = ''
|
||||
ownPasswordForm.newPassword = ''
|
||||
ownPasswordForm.confirmPassword = ''
|
||||
passwordModalOpen.value = true
|
||||
}
|
||||
|
||||
async function changeOwnPassword() {
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
|
||||
if (ownPasswordForm.newPassword.length < 10) {
|
||||
profileError.value = 'Das neue Passwort muss mindestens 10 Zeichen lang sein.'
|
||||
return
|
||||
}
|
||||
|
||||
if (ownPasswordForm.newPassword !== ownPasswordForm.confirmPassword) {
|
||||
profileError.value = 'Die neuen Passwörter stimmen nicht überein.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.changePassword({
|
||||
currentPassword: ownPasswordForm.currentPassword,
|
||||
newPassword: ownPasswordForm.newPassword,
|
||||
})
|
||||
profileSuccess.value = 'Dein Passwort wurde geändert.'
|
||||
passwordModalOpen.value = false
|
||||
} catch (error) {
|
||||
profileError.value = error instanceof Error
|
||||
? error.message
|
||||
: 'Passwort konnte nicht geändert werden.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Team"
|
||||
description="Mitglieder-Logins, Rollen und Berechtigungen lokal verwalten."
|
||||
:icon="Users"
|
||||
/>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<Card
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
class="p-4"
|
||||
>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-slate-400">{{ item.label }}</p>
|
||||
<div class="mt-2 flex items-end justify-between gap-3">
|
||||
<strong class="text-3xl leading-none text-violet-800">{{ item.value }}</strong>
|
||||
<span class="text-xs font-semibold text-slate-500">{{ item.note }}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card v-if="authStore.isOwnerOrCreator" class="p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
|
||||
<LockKeyhole class="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Eigener Zugang</p>
|
||||
<h2 class="mt-1 text-lg font-bold text-slate-900">Owner/Creator Passwort</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ canChangeOwnPassword ? 'Ändere dein eigenes Team-Passwort jederzeit selbst.' : 'Demo-Owner-Passwörter werden weiter in den Einstellungen gepflegt.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" :disabled="!canChangeOwnPassword || authStore.loading" @click="openPasswordModal">
|
||||
<KeyRound class="mr-2 h-4 w-4" />
|
||||
Passwort ändern
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="profileError" class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
|
||||
{{ profileError }}
|
||||
</div>
|
||||
<div v-if="profileSuccess" class="mt-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||
{{ profileSuccess }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div v-if="errorMessage" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="successMessage" class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<Card v-if="generatedPassword" class="border-amber-200 bg-amber-50/80 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-amber-600">Temporäres Passwort</p>
|
||||
<p class="mt-1 text-sm text-amber-900">
|
||||
Für <strong>{{ generatedPasswordLogin }}</strong>. Beim ersten Login wird ein neues Passwort verlangt.
|
||||
</p>
|
||||
</div>
|
||||
<code class="rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm font-bold text-amber-900">{{ generatedPassword }}</code>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4 md:p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Mitglieder</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Logins und Status</h2>
|
||||
</div>
|
||||
<Button size="sm" type="button" @click="openCreateMemberModal">
|
||||
<UserPlus class="mr-2 h-4 w-4" />
|
||||
Neuer Login
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-5 rounded-xl border border-dashed border-violet-200 p-4 text-sm text-slate-500">
|
||||
Team wird geladen...
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 overflow-x-auto rounded-xl border border-violet-100">
|
||||
<table class="min-w-[1040px] divide-y divide-violet-100 text-sm">
|
||||
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
|
||||
<tr>
|
||||
<th class="px-5 py-3">Login</th>
|
||||
<th class="px-5 py-3">Rolle</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Letzter Login</th>
|
||||
<th class="px-5 py-3 text-right">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-violet-100 bg-white/70">
|
||||
<tr
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="align-middle"
|
||||
:class="selectedMember?.id === member.id ? 'bg-violet-50/70' : ''"
|
||||
>
|
||||
<td class="px-5 py-4">
|
||||
<button class="text-left transition hover:text-violet-700" type="button" @click="openEditMemberModal(member)">
|
||||
<span class="block font-bold text-slate-900">{{ member.displayName }}</span>
|
||||
<span class="text-xs text-slate-500">@{{ member.login }}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-5 py-4 font-semibold text-slate-600">{{ roleLabel(member.role) }}</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-bold" :class="member.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
|
||||
{{ memberStatus(member) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm font-semibold text-slate-500">{{ formatDate(member.lastLoginAt) }}</td>
|
||||
<td class="px-5 py-4 text-right">
|
||||
<div class="flex flex-wrap items-center justify-end gap-5">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-bold text-slate-700 transition hover:text-violet-700 disabled:cursor-not-allowed disabled:text-slate-300"
|
||||
:disabled="saving"
|
||||
@click="openEditMemberModal(member)"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!canResetPasswords || saving"
|
||||
@click="resetMemberPassword(member)"
|
||||
>
|
||||
<RotateCcw class="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-sm font-bold text-slate-700 transition hover:text-rose-600 disabled:cursor-not-allowed disabled:text-slate-300"
|
||||
:disabled="saving || !canDeleteMember(member)"
|
||||
@click="openDeleteMemberModal(member)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
|
||||
<h2 class="mt-1 text-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Speicherbutton
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-violet-100">
|
||||
<table class="min-w-[920px] divide-y divide-violet-100 text-sm">
|
||||
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
|
||||
<tr>
|
||||
<th class="sticky left-0 z-10 bg-violet-50/95 px-3 py-2">Menüpunkt</th>
|
||||
<th v-for="role in roles" :key="role.key" class="px-3 py-2 text-center">
|
||||
{{ role.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-violet-100 bg-white/70">
|
||||
<tr v-for="permission in permissions" :key="permission.key">
|
||||
<td class="sticky left-0 z-10 max-w-[260px] bg-white px-3 py-3">
|
||||
<span class="block font-bold text-slate-900">{{ permission.label }}</span>
|
||||
<span class="block text-xs leading-5 text-slate-500">{{ permission.description }}</span>
|
||||
</td>
|
||||
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-3 text-center">
|
||||
<input
|
||||
class="h-4 w-4 accent-violet-600 disabled:opacity-60"
|
||||
type="checkbox"
|
||||
:checked="roleHasPermission(role.key, permission.key)"
|
||||
:disabled="role.key === 'owner' || role.key === 'creator'"
|
||||
:aria-label="`${role.label}: ${permission.label}`"
|
||||
@change="handlePermissionChange(role.key, permission.key, $event)"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="createMemberModalOpen || editMemberModalOpen"
|
||||
class="fixed inset-0 z-[310] flex items-center justify-center bg-slate-950/45 p-5 backdrop-blur-sm"
|
||||
@click.self="closeMemberModals"
|
||||
>
|
||||
<Card class="w-full max-w-xl p-5 md:p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid h-12 w-12 place-items-center rounded-2xl bg-violet-50 text-violet-600">
|
||||
<KeyRound class="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">
|
||||
{{ createMemberModalOpen ? 'Login erstellen' : 'Profil bearbeiten' }}
|
||||
</p>
|
||||
<h2 class="mt-1 text-2xl font-bold text-slate-900">
|
||||
{{ createMemberModalOpen ? 'Neues Mitglied' : memberForm.displayName }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" @click="closeMemberModals">Schließen</Button>
|
||||
</div>
|
||||
|
||||
<form class="mt-6 grid gap-5" @submit.prevent="submitMemberForm">
|
||||
<label class="grid gap-2 text-base font-bold text-slate-600">
|
||||
Login
|
||||
<input
|
||||
v-model.trim="memberForm.login"
|
||||
class="h-12 rounded-2xl border border-violet-100 bg-white px-4 text-base font-bold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
:disabled="editMemberModalOpen && !canEditMemberLogin"
|
||||
placeholder="z.B. reviewer_mina"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-base font-bold text-slate-600">
|
||||
Anzeigename
|
||||
<input
|
||||
v-model.trim="memberForm.displayName"
|
||||
class="h-12 rounded-2xl border border-violet-100 bg-white px-4 text-base font-bold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="Name im Admin-Panel"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-base font-bold text-slate-600">
|
||||
Rolle
|
||||
<select
|
||||
v-model="memberForm.role"
|
||||
class="h-12 rounded-2xl border border-violet-100 bg-white px-4 text-base font-bold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
>
|
||||
<option v-for="role in roleOptions" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label v-if="editMemberModalOpen" class="flex h-14 items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 text-base font-bold text-slate-700">
|
||||
Account aktiv
|
||||
<input v-model="memberForm.isActive" type="checkbox" class="h-5 w-5 accent-violet-600">
|
||||
</label>
|
||||
|
||||
<div v-if="errorMessage" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="saving" class="h-14 text-base">
|
||||
<Save class="mr-2 h-5 w-5" />
|
||||
{{ saving ? 'Speichert...' : createMemberModalOpen ? 'Login erstellen' : 'Speichern' }}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="memberPendingDeletion"
|
||||
class="fixed inset-0 z-[315] flex items-center justify-center bg-slate-950/45 p-5 backdrop-blur-sm"
|
||||
@click.self="cancelDeleteMember"
|
||||
>
|
||||
<Card class="w-full max-w-md p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid h-10 w-10 place-items-center rounded-2xl bg-rose-50 text-rose-600">
|
||||
<Trash2 class="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-rose-500">Account löschen</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ memberPendingDeletion.displayName }}</h2>
|
||||
<p class="mt-2 text-sm font-semibold leading-6 text-slate-500">
|
||||
Dieser Team-Login wird entfernt. Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap justify-end gap-3">
|
||||
<Button type="button" variant="ghost" :disabled="saving" @click="cancelDeleteMember">Abbrechen</Button>
|
||||
<Button type="button" variant="secondary" :disabled="saving" @click="confirmDeleteMember">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{{ saving ? 'Löscht...' : 'Löschen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="passwordModalOpen"
|
||||
class="fixed inset-0 z-[320] flex items-center justify-center bg-slate-950/45 p-5 backdrop-blur-sm"
|
||||
@click.self="passwordModalOpen = false"
|
||||
>
|
||||
<Card class="w-full max-w-md p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
|
||||
<KeyRound class="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Passwort</p>
|
||||
<h2 class="mt-1 text-lg font-bold text-slate-900">Eigenes Passwort ändern</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" @click="passwordModalOpen = false">Schließen</Button>
|
||||
</div>
|
||||
|
||||
<form class="mt-5 grid gap-4" @submit.prevent="changeOwnPassword">
|
||||
<label class="grid gap-1.5 text-sm font-semibold text-slate-600">
|
||||
Aktuelles Passwort
|
||||
<PasswordField v-model="ownPasswordForm.currentPassword" autocomplete="current-password" required input-class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm font-semibold text-slate-600">
|
||||
Neues Passwort
|
||||
<PasswordField v-model="ownPasswordForm.newPassword" autocomplete="new-password" required minlength="10" input-class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm font-semibold text-slate-600">
|
||||
Passwort wiederholen
|
||||
<PasswordField v-model="ownPasswordForm.confirmPassword" autocomplete="new-password" required minlength="10" input-class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
|
||||
</label>
|
||||
<div v-if="profileError" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
|
||||
{{ profileError }}
|
||||
</div>
|
||||
<Button type="submit" :disabled="authStore.loading">
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
{{ authStore.loading ? 'Speichert...' : 'Passwort speichern' }}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user