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:
parent
25370ebfbc
commit
cac6b26a0d
@ -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))
|
||||
|
||||
@ -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') },
|
||||
|
||||
@ -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>
|
||||
|
||||
150
frontend/src/views/UsersView.vue
Normal file
150
frontend/src/views/UsersView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user