621 lines
28 KiB
Vue
621 lines
28 KiB
Vue
<script setup lang="ts">
|
|
import { Check, KeyRound, LockKeyhole, Minus, Pencil, RefreshCw, 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 { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
|
import { watchAdminToast } from '../../composables/useAdminToast'
|
|
import Button from '../../components/ui/Button.vue'
|
|
import Card from '../../components/ui/Card.vue'
|
|
import NativeSelect from '../../components/ui/NativeSelect.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 rolePermissionsModalOpen = ref(false)
|
|
const profileError = ref('')
|
|
const profileSuccess = ref('')
|
|
const ownPasswordForm = reactive({
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
})
|
|
|
|
const {
|
|
loading,
|
|
saving,
|
|
refreshingPresence,
|
|
lastPresenceRefreshAt,
|
|
errorMessage,
|
|
successMessage,
|
|
generatedPassword,
|
|
generatedPasswordLogin,
|
|
confirmDeleteMemberId,
|
|
members,
|
|
roles,
|
|
permissions,
|
|
memberForm,
|
|
selectedMember,
|
|
pendingPasswordChanges,
|
|
assignableRoleOptions,
|
|
hasRoleChanges,
|
|
loadTeam,
|
|
startCreateMember,
|
|
startEditMember,
|
|
saveMember,
|
|
resetMemberPassword,
|
|
requestDeleteMember,
|
|
cancelDeleteMember,
|
|
deleteMember,
|
|
roleHasPermission,
|
|
setRolePermission,
|
|
saveRolePermissions,
|
|
} = useAdminTeamManager()
|
|
|
|
watchAdminToast(successMessage, errorMessage)
|
|
watchAdminToast(profileSuccess, profileError)
|
|
|
|
const memberPendingDeletion = computed(() =>
|
|
members.value.find((member) => member.id === confirmDeleteMemberId.value) ?? null,
|
|
)
|
|
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(() => [
|
|
{ label: 'Mitglieder', value: members.value.length, note: `${onlineMembers.value} online` },
|
|
{ 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 formatRefreshTime(value: Date | null) {
|
|
if (!value) return 'noch nicht aktualisiert'
|
|
return new Intl.DateTimeFormat('de-DE', {
|
|
timeStyle: 'short',
|
|
}).format(value)
|
|
}
|
|
|
|
function memberAccountStatus(member: AdminTeamMember) {
|
|
if (!member.isActive) return 'Inaktiv'
|
|
return member.mustChangePassword ? 'PW-Wechsel offen' : 'Aktiv'
|
|
}
|
|
|
|
function memberOnlineStatus(member: AdminTeamMember) {
|
|
return member.isOnline ? 'Online' : 'Offline'
|
|
}
|
|
|
|
function rolePermissionLocked(roleKey: string) {
|
|
return roleKey === 'owner' || roleKey === 'creator'
|
|
}
|
|
|
|
function rolePermissionCount(roleKey: string) {
|
|
return permissions.value.filter((permission) => roleHasPermission(roleKey, permission.key)).length
|
|
}
|
|
|
|
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 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>
|
|
</Card>
|
|
|
|
<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>
|
|
<p class="mt-1 text-xs font-semibold text-slate-500">
|
|
Online = aktive Admin-Session in den letzten 5 Minuten. Auto-Refresh alle 30 Sekunden.
|
|
<span class="text-slate-400">Zuletzt geprüft: {{ formatRefreshTime(lastPresenceRefreshAt) }}</span>
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
type="button"
|
|
variant="ghost"
|
|
:disabled="loading || refreshingPresence"
|
|
@click="loadTeam({ silent: true })"
|
|
>
|
|
<RefreshCw class="mr-2 h-4 w-4" :class="refreshingPresence ? 'animate-spin' : ''" />
|
|
Aktualisieren
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
type="button"
|
|
variant="ghost"
|
|
class="border border-violet-200 bg-violet-50 text-violet-700 shadow-sm shadow-violet-200/40 hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-100 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/60"
|
|
@click="rolePermissionsModalOpen = true"
|
|
>
|
|
<ShieldCheck class="mr-2 h-4 w-4" />
|
|
Rollenrechte
|
|
</Button>
|
|
<Button size="sm" type="button" @click="openCreateMemberModal">
|
|
<UserPlus class="mr-2 h-4 w-4" />
|
|
Neuer Login
|
|
</Button>
|
|
</div>
|
|
</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">Online</th>
|
|
<th class="px-5 py-3">Zuletzt online</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">
|
|
<div class="flex flex-col items-start gap-1.5">
|
|
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold" :class="member.isOnline ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
|
|
<span class="h-2 w-2 rounded-full" :class="member.isOnline ? 'bg-emerald-500' : 'bg-slate-300'" />
|
|
{{ memberOnlineStatus(member) }}
|
|
</span>
|
|
<span v-if="memberAccountStatus(member) !== 'Aktiv'" class="text-xs font-semibold text-amber-600">
|
|
{{ memberAccountStatus(member) }}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-5 py-4 text-sm font-semibold text-slate-500">{{ formatDate(member.lastOnlineAt) }}</td>
|
|
<td class="px-5 py-4 text-right">
|
|
<div class="flex flex-wrap items-center justify-end gap-5">
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-9 items-center gap-2 rounded-xl border border-violet-200 bg-white px-4 text-sm font-bold text-violet-700 shadow-sm shadow-violet-100/60 transition hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-50 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-300 disabled:shadow-none disabled:hover:translate-y-0"
|
|
:disabled="saving"
|
|
@click="openEditMemberModal(member)"
|
|
>
|
|
<Pencil class="h-4 w-4" />
|
|
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 h-9 items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 px-4 text-sm font-bold text-rose-700 shadow-sm shadow-rose-100/60 transition-all duration-200 hover:-translate-y-0.5 hover:border-rose-300 hover:bg-rose-100 hover:text-rose-800 hover:shadow-lg hover:shadow-rose-200/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-300 disabled:shadow-none disabled:hover:translate-y-0"
|
|
:disabled="saving || !canDeleteMember(member)"
|
|
@click="openDeleteMemberModal(member)"
|
|
>
|
|
<Trash2 class="h-4 w-4" />
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="rolePermissionsModalOpen"
|
|
class="fixed inset-0 z-[305] flex items-center justify-center bg-slate-950/50 p-3 backdrop-blur-sm md:p-6"
|
|
@click.self="rolePermissionsModalOpen = false"
|
|
>
|
|
<Card class="flex max-h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-[30px] bg-white p-0 shadow-[0_34px_100px_rgba(39,28,72,0.28)]">
|
|
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-violet-100 bg-white px-5 py-4 md:px-6">
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-50 text-violet-600 ring-1 ring-violet-100">
|
|
<ShieldCheck class="h-5 w-5" />
|
|
</span>
|
|
<div class="min-w-0">
|
|
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
|
|
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-950">Berechtigungen einstellen</h2>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
|
<Button type="button" variant="ghost" @click="rolePermissionsModalOpen = false">Schließen</Button>
|
|
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
|
|
<Save class="mr-2 h-4 w-4" />
|
|
Speichern
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="min-h-0 flex-1 overflow-auto bg-slate-50/70 p-3 md:p-5">
|
|
<table class="w-full min-w-[1040px] overflow-hidden rounded-2xl border border-violet-100 bg-white text-sm shadow-sm">
|
|
<thead class="sticky top-0 z-20 bg-violet-50 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500 shadow-[0_1px_0_rgba(221,214,254,0.85)]">
|
|
<tr>
|
|
<th class="sticky left-0 z-30 w-[340px] bg-violet-50 px-5 py-4">Bereich</th>
|
|
<th v-for="role in roles" :key="role.key" class="min-w-[112px] px-3 py-4 text-center">
|
|
<span class="block text-[11px] leading-4 text-violet-600">{{ role.label }}</span>
|
|
<span class="mt-1 block text-[10px] font-extrabold tracking-normal text-slate-400">
|
|
{{ rolePermissionCount(role.key) }}/{{ permissions.length }}
|
|
</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-violet-100 bg-white">
|
|
<tr v-for="permission in permissions" :key="permission.key" class="transition hover:bg-violet-50/35">
|
|
<td class="sticky left-0 z-10 w-[340px] bg-white px-5 py-4 shadow-[1px_0_0_rgba(237,233,254,0.95)]">
|
|
<span class="block text-base font-bold leading-5 text-slate-950">{{ permission.label }}</span>
|
|
<span class="mt-1 block max-w-[300px] text-xs font-semibold leading-5 text-slate-500">{{ permission.description }}</span>
|
|
</td>
|
|
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-4 text-center">
|
|
<button
|
|
type="button"
|
|
class="relative mx-auto grid h-9 w-9 place-items-center rounded-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
|
:class="[
|
|
roleHasPermission(role.key, permission.key)
|
|
? rolePermissionLocked(role.key)
|
|
? 'bg-slate-100 text-slate-400 ring-slate-200'
|
|
: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 ring-violet-500 hover:bg-violet-500'
|
|
: rolePermissionLocked(role.key)
|
|
? 'bg-slate-50 text-slate-300 ring-slate-200'
|
|
: 'bg-white text-slate-300 ring-slate-300 hover:text-violet-500 hover:ring-violet-300',
|
|
]"
|
|
:disabled="rolePermissionLocked(role.key)"
|
|
:aria-label="`${role.label}: ${permission.label}`"
|
|
:aria-pressed="roleHasPermission(role.key, permission.key)"
|
|
@click="setRolePermission(role.key, permission.key, !roleHasPermission(role.key, permission.key))"
|
|
>
|
|
<Check v-if="roleHasPermission(role.key, permission.key)" class="h-5 w-5" :stroke-width="3" />
|
|
<Minus v-else class="h-4 w-4" :stroke-width="3" />
|
|
<span
|
|
v-if="rolePermissionLocked(role.key) && roleHasPermission(role.key, permission.key)"
|
|
class="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-white text-slate-400 ring-1 ring-slate-200"
|
|
>
|
|
<LockKeyhole class="h-2.5 w-2.5" />
|
|
</span>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<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
|
|
<NativeSelect
|
|
v-model="memberForm.role"
|
|
:options="assignableRoleOptions"
|
|
/>
|
|
</label>
|
|
|
|
<label
|
|
v-if="editMemberModalOpen"
|
|
class="flex h-14 cursor-pointer items-center justify-between gap-3 rounded-2xl border px-4 text-base font-bold shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
|
:class="memberForm.isActive ? 'border-violet-200 bg-violet-50 text-violet-800 shadow-violet-100/60 hover:border-violet-300 hover:bg-violet-100' : 'border-slate-200 bg-white text-slate-600 shadow-slate-100 hover:border-slate-300 hover:bg-slate-50'"
|
|
>
|
|
<span>Account aktiv</span>
|
|
<span
|
|
class="grid h-9 w-9 place-items-center rounded-xl border transition"
|
|
:class="memberForm.isActive ? 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20' : 'border-slate-300 bg-white text-slate-300'"
|
|
>
|
|
<Check v-if="memberForm.isActive" class="h-5 w-5" :stroke-width="3" />
|
|
<Minus v-else class="h-4 w-4" :stroke-width="3" />
|
|
</span>
|
|
<input v-model="memberForm.isActive" type="checkbox" class="sr-only">
|
|
</label>
|
|
|
|
<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>
|
|
<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>
|