Rechte: Benutzer-Verwaltung & Rechtegruppe je Mitarbeiter (Frontend)

- UsersView: Benutzer-Liste + Anlegen (Rechtegruppen-Dropdown = nur erlaubte
  Gruppen), scope-gefiltert; Nav-Eintrag Benutzer (ab Firmen-Admin)
- EmployeesView: Block 'Zugang/Rechtegruppe' im Bearbeiten-Dialog —
  Login anlegen/entfernen pro Mitarbeiter (delegationsgeprüft)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-01 15:23:34 +02:00
parent 25370ebfbc
commit cac6b26a0d
4 changed files with 214 additions and 0 deletions

View File

@ -16,6 +16,7 @@ const nav = computed<NavItem[]>(() => [
{ label: 'Visitenkarten', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: true },
{ label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin || auth.isResellerAdmin },
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin || auth.isResellerAdmin },
{ label: 'Benutzer', to: '/app/users', icon: 'M17 21v-2a4 4 0 0 0-3-3.87M9 21v-2a4 4 0 0 0-3-3.87M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 11l-2 2-1-1', show: auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: true },
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: true },
].filter((i) => i.show))

View File

@ -20,6 +20,7 @@ const router = createRouter({
{ path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') },
{ path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') },
{ path: 'card-editor', name: 'card-editor', component: () => import('@/views/CardEditorView.vue') },
{ path: 'users', name: 'users', component: () => import('@/views/UsersView.vue') },
{ path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
{ path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') },

View File

@ -1,9 +1,15 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { list, create, update, remove } from '@/api/resources'
import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
const GROUP_LABEL: Record<string, string> = {
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter',
}
interface Employee {
'@id': string
id: string
@ -32,6 +38,11 @@ const locations = ref<Location[]>([])
const loading = ref(true)
const search = ref('')
const canManageUsers = computed(() => auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin)
const usersByEmployee = ref<Record<string, { id: string; group: string }>>({})
const assignableGroups = ref<string[]>([])
const loginForm = ref({ group: 'employee', password: '' })
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c])))
const filtered = computed(() => {
const q = search.value.toLowerCase().trim()
@ -58,9 +69,35 @@ async function load() {
list<Location>('locations').then((r) => r.member).catch(() => []),
list<Employee>('employees').then((r) => r.member),
])
if (canManageUsers.value) {
try {
const [u, g] = await Promise.all([client.get('/users'), client.get('/users/assignable-groups')])
usersByEmployee.value = Object.fromEntries(
u.data.member.filter((x: any) => x.employeeId).map((x: any) => [x.employeeId, { id: x.id, group: x.group }]),
)
assignableGroups.value = g.data.groups
} catch { /* Mitarbeiter-Rolle darf /users nicht egal */ }
}
loading.value = false
}
async function grantLogin(e: Employee) {
if (!e.email) { alert('Der Mitarbeiter braucht eine E-Mail für den Login.'); return }
try {
await client.post('/users', { email: e.email, password: loginForm.value.password, group: loginForm.value.group, employeeId: e.id })
loginForm.value = { group: 'employee', password: '' }
await load()
} catch (err: any) {
alert(err?.response?.data?.error ?? 'Login anlegen fehlgeschlagen.')
}
}
async function removeLogin(e: Employee) {
const u = usersByEmployee.value[e.id]
if (!u || !confirm('Login für diesen Mitarbeiter entfernen?')) return
await client.delete(`/users/${u.id}`)
await load()
}
// --- Anlegen / Bearbeiten ---
const showForm = ref(false)
const saving = ref(false)
@ -225,6 +262,29 @@ onMounted(load)
<button type="button" class="btn btn-ghost btn-sm" @click="copyShort(editing)">Kopieren</button>
</div>
</div>
<div v-if="editing && canManageUsers" class="login-box">
<div class="nfc__label">Zugang / Rechtegruppe</div>
<div v-if="usersByEmployee[editing.id]" class="nfc__row">
<span>Login aktiv: <strong>{{ GROUP_LABEL[usersByEmployee[editing.id].group] }}</strong> · {{ editing.email }}</span>
<button type="button" class="btn btn-ghost btn-sm" @click="removeLogin(editing)">Login entfernen</button>
</div>
<template v-else>
<div class="grid2">
<div class="field">
<label>Rechtegruppe</label>
<select class="input" v-model="loginForm.group">
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
</select>
</div>
<div class="field"><label>Passwort</label><input class="input" type="password" v-model="loginForm.password" minlength="6" /></div>
</div>
<button type="button" class="btn btn-soft btn-sm" :disabled="!loginForm.password" @click="grantLogin(editing)">
Login anlegen ({{ editing.email || 'E-Mail fehlt' }})
</button>
</template>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
@ -257,4 +317,6 @@ onMounted(load)
.nfc__label { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin-bottom: .4rem; }
.nfc__row { display: flex; align-items: center; justify-content: space-between; gap: .6rem; }
.nfc__row code { font-size: .82rem; word-break: break-all; }
.login-box { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .6rem; }
.login-box .grid2 { margin-bottom: .4rem; }
</style>

View File

@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { list } from '@/api/resources'
import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
interface AdminUser {
id: string; email: string; group: string; status: string
reseller: { id: string; name: string } | null
company: { id: string; name: string } | null
employeeId: string | null
}
interface Company { '@id': string; id: string; name: string }
interface Reseller { '@id': string; id: string; name: string }
const auth = useAuthStore()
const GROUP_LABEL: Record<string, string> = {
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter',
}
const users = ref<AdminUser[]>([])
const groups = ref<string[]>([])
const companies = ref<Company[]>([])
const resellers = ref<Reseller[]>([])
const loading = ref(true)
const showForm = ref(false)
const saving = ref(false)
const error = ref('')
const blank = () => ({ email: '', password: '', group: '', companyId: '', resellerId: '' })
const form = ref(blank())
const isCompanyLevel = computed(() => ['company_admin', 'employee'].includes(form.value.group))
const needsResellerPick = computed(() => form.value.group === 'reseller_admin' && auth.isPlatformAdmin)
async function load() {
loading.value = true
const [u, g] = await Promise.all([
client.get('/users').then((r) => r.data),
client.get('/users/assignable-groups').then((r) => r.data),
])
users.value = u.member
groups.value = g.groups
companies.value = (await list<Company>('companies').then((r) => r.member).catch(() => []))
if (auth.isPlatformAdmin) resellers.value = await list<Reseller>('resellers').then((r) => r.member).catch(() => [])
loading.value = false
}
function openCreate() {
form.value = blank()
form.value.group = groups.value[groups.value.length - 1] ?? '' // niedrigste Gruppe vorwählen
if (companies.value.length === 1) form.value.companyId = companies.value[0].id
error.value = ''
showForm.value = true
}
async function submit() {
saving.value = true; error.value = ''
const payload: Record<string, unknown> = { email: form.value.email, password: form.value.password, group: form.value.group }
if (isCompanyLevel.value && form.value.companyId) payload.companyId = form.value.companyId
if (needsResellerPick.value && form.value.resellerId) payload.resellerId = form.value.resellerId
try {
await client.post('/users', payload)
showForm.value = false; await load()
} catch (e: any) {
error.value = e?.response?.data?.error ?? e?.response?.data?.detail ?? 'Anlegen fehlgeschlagen.'
} finally { saving.value = false }
}
async function del(u: AdminUser) {
if (!confirm(`Benutzer „${u.email}" löschen?`)) return
try { await client.delete(`/users/${u.id}`); await load() }
catch { alert('Löschen fehlgeschlagen.') }
}
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div><h1>Benutzer</h1><p class="muted">Logins & Rechtegruppen Vergabe nur innerhalb der eigenen Ebene</p></div>
<button class="btn btn-primary" @click="openCreate">+ Benutzer anlegen</button>
</div>
<div class="card">
<table class="tbl">
<thead><tr><th>E-Mail</th><th>Rechtegruppe</th><th>Zuordnung</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr v-if="loading"><td colspan="5" class="empty">Lädt</td></tr>
<tr v-else-if="!users.length"><td colspan="5" class="empty">Keine Benutzer.</td></tr>
<tr v-for="u in users" :key="u.id">
<td><strong>{{ u.email }}</strong></td>
<td><span class="badge badge-role">{{ GROUP_LABEL[u.group] ?? u.group }}</span></td>
<td class="muted">{{ u.company?.name ?? u.reseller?.name ?? 'Plattform' }}</td>
<td><span class="badge" :class="u.status === 'active' ? 'badge-active' : 'badge-inactive'">{{ u.status }}</span></td>
<td class="right"><button class="btn btn-ghost btn-sm" @click="del(u)">Löschen</button></td>
</tr>
</tbody>
</table>
</div>
<Modal v-if="showForm" title="Benutzer anlegen" @close="showForm = false">
<form @submit.prevent="submit">
<div class="field"><label>E-Mail</label><input class="input" v-model="form.email" type="email" required /></div>
<div class="field"><label>Passwort</label><input class="input" v-model="form.password" type="password" required minlength="6" /></div>
<div class="field">
<label>Rechtegruppe</label>
<select class="input" v-model="form.group">
<option v-for="g in groups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
</select>
</div>
<div class="field" v-if="isCompanyLevel && companies.length > 1">
<label>Firma</label>
<select class="input" v-model="form.companyId">
<option value=""> wählen </option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field" v-if="needsResellerPick">
<label>Reseller</label>
<select class="input" v-model="form.resellerId">
<option value=""> wählen </option>
<option v-for="r in resellers" :key="r.id" :value="r.id">{{ r.name }}</option>
</select>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Anlegen' }}</button>
</div>
</form>
</Modal>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .9rem 1.2rem; border-bottom: 1px solid var(--line); }
.tbl td { padding: .9rem 1.2rem; border-bottom: 1px solid #f4f4f4; }
.tbl tr:last-child td { border-bottom: none; }
.right { text-align: right; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.error { color: var(--danger); font-size: .88rem; }
</style>