Rechte: Mitarbeiter & Benutzer vereint, ROLE_CONTACT als Basis
- Jeder Mitarbeiter hat (leere) Login-/Passwortfelder; Standardrolle ROLE_CONTACT
(reines Profil). Hochstufen über die Rechtegruppe.
- Ebenen-Ladder: contact(0) < employee(1) < company_admin(2) < reseller_admin(3)
< platform_admin(4); role_hierarchy + RoleService entsprechend.
- PATCH /api/employees/{id}/access: Rechtegruppe setzen (+ optional Passwort/Login);
DELETE .../login → zurück auf Kontakt.
- Sicherheit: Passwort/userIdentifier per #[Ignore] aus der API-Serialisierung.
- Frontend: separate Benutzer-Ansicht entfernt; Mitarbeiter-Liste mit
Rechtegruppe-Spalte, Rollen/Login + 'Arbeiten als' inline im Bearbeiten-Dialog.
Verifiziert: kein Passwort-Leak, roles/login im Payload, Hochstufen Kontakt→
Mitarbeiter + Login, Eskalation→403, Login entziehen→Kontakt; UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ae9936586b
commit
2dc40c6ea5
@ -41,9 +41,10 @@ security:
|
|||||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE]
|
ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE, ROLE_CONTACT]
|
||||||
ROLE_RESELLER_ADMIN: [ROLE_COMPANY_ADMIN]
|
ROLE_RESELLER_ADMIN: [ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE, ROLE_CONTACT]
|
||||||
ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE]
|
ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE, ROLE_CONTACT]
|
||||||
|
ROLE_EMPLOYEE: [ROLE_CONTACT]
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
security:
|
security:
|
||||||
|
|||||||
@ -66,23 +66,27 @@ final class UserAdminController
|
|||||||
return new JsonResponse(['member' => $rows, 'totalItems' => count($rows)]);
|
return new JsonResponse(['member' => $rows, 'totalItems' => count($rows)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/employees/{id}/login', name: 'employee_login_grant', methods: ['POST'])]
|
/** Rechtegruppe setzen (+ optional Login/Passwort) — Hochstufen via Rolle. */
|
||||||
public function grant(string $id, Request $request): JsonResponse
|
#[Route('/api/employees/{id}/access', name: 'employee_access', methods: ['PATCH'])]
|
||||||
|
public function setAccess(string $id, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$employee = $this->employee($id);
|
$employee = $this->employee($id);
|
||||||
$d = json_decode($request->getContent(), true) ?? [];
|
$d = json_decode($request->getContent(), true) ?? [];
|
||||||
$group = (string) ($d['group'] ?? '');
|
$group = (string) ($d['group'] ?? '');
|
||||||
$password = (string) ($d['password'] ?? '');
|
|
||||||
$loginEmail = trim((string) ($d['loginEmail'] ?? $employee->getEmail() ?? ''));
|
|
||||||
|
|
||||||
if ('' === $password || '' === $loginEmail) {
|
|
||||||
throw new BadRequestHttpException('Login-E-Mail und Passwort erforderlich.');
|
|
||||||
}
|
|
||||||
$this->roles->assertCanAssign($group, $employee->getCompany()->getReseller(), $employee->getCompany());
|
$this->roles->assertCanAssign($group, $employee->getCompany()->getReseller(), $employee->getCompany());
|
||||||
|
$employee->setRoles([$this->roles->roleForGroup($group)]);
|
||||||
|
|
||||||
$employee->setLoginEmail($loginEmail)
|
// Passwort optional: setzt/aktiviert das Login
|
||||||
->setRoles([$this->roles->roleForGroup($group)]);
|
$password = (string) ($d['password'] ?? '');
|
||||||
|
if ('' !== $password) {
|
||||||
|
$loginEmail = trim((string) ($d['loginEmail'] ?? $employee->getLoginEmail() ?? $employee->getEmail() ?? ''));
|
||||||
|
if ('' === $loginEmail) {
|
||||||
|
throw new BadRequestHttpException('Login-E-Mail erforderlich (oder Kontakt-E-Mail setzen).');
|
||||||
|
}
|
||||||
|
$employee->setLoginEmail($loginEmail);
|
||||||
$employee->setPassword($this->hasher->hashPassword($employee, $password));
|
$employee->setPassword($this->hasher->hashPassword($employee, $password));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
@ -90,9 +94,10 @@ final class UserAdminController
|
|||||||
throw new BadRequestHttpException('Login-E-Mail bereits vergeben.');
|
throw new BadRequestHttpException('Login-E-Mail bereits vergeben.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JsonResponse($this->serialize($employee), 201);
|
return new JsonResponse($this->serialize($employee));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Login entziehen → zurück auf reinen Kontakt. */
|
||||||
#[Route('/api/employees/{id}/login', name: 'employee_login_revoke', methods: ['DELETE'])]
|
#[Route('/api/employees/{id}/login', name: 'employee_login_revoke', methods: ['DELETE'])]
|
||||||
public function revoke(string $id): JsonResponse
|
public function revoke(string $id): JsonResponse
|
||||||
{
|
{
|
||||||
@ -100,7 +105,7 @@ final class UserAdminController
|
|||||||
if ($this->tenant->getEmployee() === $employee) {
|
if ($this->tenant->getEmployee() === $employee) {
|
||||||
throw new AccessDeniedHttpException('Eigenen Zugang nicht entziehen.');
|
throw new AccessDeniedHttpException('Eigenen Zugang nicht entziehen.');
|
||||||
}
|
}
|
||||||
$employee->setLoginEmail(null)->setPassword(null)->setRoles([]);
|
$employee->setLoginEmail(null)->setPassword(null)->setRoles([Employee::ROLE_CONTACT]);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return new JsonResponse(null, 204);
|
return new JsonResponse(null, 204);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ use Doctrine\ORM\Mapping as ORM;
|
|||||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Ignore;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -29,6 +30,8 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
|
|||||||
public const ROLE_RESELLER_ADMIN = 'ROLE_RESELLER_ADMIN';
|
public const ROLE_RESELLER_ADMIN = 'ROLE_RESELLER_ADMIN';
|
||||||
public const ROLE_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN';
|
public const ROLE_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN';
|
||||||
public const ROLE_EMPLOYEE = 'ROLE_EMPLOYEE';
|
public const ROLE_EMPLOYEE = 'ROLE_EMPLOYEE';
|
||||||
|
/** Basis-Rolle: reines Profil/Kontakt (Visitenkarte), kein Login nötig. */
|
||||||
|
public const ROLE_CONTACT = 'ROLE_CONTACT';
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
@ -102,9 +105,9 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
|
|||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
private ?string $password = null;
|
private ?string $password = null;
|
||||||
|
|
||||||
/** @var string[] Rechtegruppe(n); leer = nur Profil ohne Adminrechte. */
|
/** @var string[] Rechtegruppe(n); Standard = ROLE_CONTACT (reines Profil). */
|
||||||
#[ORM\Column(type: 'json')]
|
#[ORM\Column(type: 'json')]
|
||||||
private array $roles = [];
|
private array $roles = [self::ROLE_CONTACT];
|
||||||
|
|
||||||
/** @var Collection<int, ContactLink> */
|
/** @var Collection<int, ContactLink> */
|
||||||
#[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
#[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
@ -356,6 +359,7 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
|
|||||||
return null !== $this->loginEmail && null !== $this->password;
|
return null !== $this->loginEmail && null !== $this->password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Ignore]
|
||||||
public function getUserIdentifier(): string
|
public function getUserIdentifier(): string
|
||||||
{
|
{
|
||||||
return (string) $this->loginEmail;
|
return (string) $this->loginEmail;
|
||||||
@ -378,6 +382,7 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Ignore]
|
||||||
public function getPassword(): ?string
|
public function getPassword(): ?string
|
||||||
{
|
{
|
||||||
return $this->password;
|
return $this->password;
|
||||||
|
|||||||
@ -16,12 +16,16 @@ final class RoleService
|
|||||||
{
|
{
|
||||||
/** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */
|
/** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */
|
||||||
private const GROUPS = [
|
private const GROUPS = [
|
||||||
'platform_admin' => ['role' => Employee::ROLE_PLATFORM_ADMIN, 'level' => 3],
|
'platform_admin' => ['role' => Employee::ROLE_PLATFORM_ADMIN, 'level' => 4],
|
||||||
'reseller_admin' => ['role' => Employee::ROLE_RESELLER_ADMIN, 'level' => 2],
|
'reseller_admin' => ['role' => Employee::ROLE_RESELLER_ADMIN, 'level' => 3],
|
||||||
'company_admin' => ['role' => Employee::ROLE_COMPANY_ADMIN, 'level' => 1],
|
'company_admin' => ['role' => Employee::ROLE_COMPANY_ADMIN, 'level' => 2],
|
||||||
'employee' => ['role' => Employee::ROLE_EMPLOYEE, 'level' => 0],
|
'employee' => ['role' => Employee::ROLE_EMPLOYEE, 'level' => 1],
|
||||||
|
'contact' => ['role' => Employee::ROLE_CONTACT, 'level' => 0],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Mindest-Ebene zum Verwalten anderer (Firmen-Admin). */
|
||||||
|
private const MANAGE_LEVEL = 2;
|
||||||
|
|
||||||
public function __construct(private readonly Security $security)
|
public function __construct(private readonly Security $security)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@ -40,12 +44,15 @@ final class RoleService
|
|||||||
public function actorLevel(): int
|
public function actorLevel(): int
|
||||||
{
|
{
|
||||||
if ($this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN)) {
|
if ($this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN)) {
|
||||||
return 3;
|
return 4;
|
||||||
}
|
}
|
||||||
if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) {
|
if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) {
|
||||||
return 2;
|
return 3;
|
||||||
}
|
}
|
||||||
if ($this->security->isGranted(Employee::ROLE_COMPANY_ADMIN)) {
|
if ($this->security->isGranted(Employee::ROLE_COMPANY_ADMIN)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if ($this->security->isGranted(Employee::ROLE_EMPLOYEE)) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,12 +97,12 @@ final class RoleService
|
|||||||
if ($targetLevel > $actorLevel) {
|
if ($targetLevel > $actorLevel) {
|
||||||
throw new AccessDeniedHttpException('Keine Berechtigung, diese Rechtegruppe zu vergeben.');
|
throw new AccessDeniedHttpException('Keine Berechtigung, diese Rechtegruppe zu vergeben.');
|
||||||
}
|
}
|
||||||
if ($actorLevel < 1) {
|
if ($actorLevel < self::MANAGE_LEVEL) {
|
||||||
throw new AccessDeniedHttpException('Keine Berechtigung zur Benutzerverwaltung.');
|
throw new AccessDeniedHttpException('Keine Berechtigung zur Benutzerverwaltung.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plattform-Admin: keine Mandantengrenze
|
// Plattform-Admin: keine Mandantengrenze
|
||||||
if ($actorLevel >= 3) {
|
if ($actorLevel >= 4) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +110,7 @@ final class RoleService
|
|||||||
$actor = $this->security->getUser();
|
$actor = $this->security->getUser();
|
||||||
|
|
||||||
// Regel 2: nur im eigenen Mandanten-Teilbaum
|
// Regel 2: nur im eigenen Mandanten-Teilbaum
|
||||||
if ($actorLevel === 2) { // Reseller-Admin
|
if ($actorLevel === 3) { // Reseller-Admin
|
||||||
$actorReseller = $actor->getReseller();
|
$actorReseller = $actor->getReseller();
|
||||||
$effectiveReseller = $targetCompany?->getReseller() ?? $targetReseller;
|
$effectiveReseller = $targetCompany?->getReseller() ?? $targetReseller;
|
||||||
if (null === $actorReseller || null === $effectiveReseller
|
if (null === $actorReseller || null === $effectiveReseller
|
||||||
@ -114,7 +121,7 @@ final class RoleService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($actorLevel === 1) { // Firmen-Admin
|
if ($actorLevel === 2) { // Firmen-Admin
|
||||||
$actorCompany = $actor->getCompany();
|
$actorCompany = $actor->getCompany();
|
||||||
if (null === $actorCompany || null === $targetCompany
|
if (null === $actorCompany || null === $targetCompany
|
||||||
|| !$targetCompany->getId()->equals($actorCompany->getId())) {
|
|| !$targetCompany->getId()->equals($actorCompany->getId())) {
|
||||||
|
|||||||
@ -16,7 +16,6 @@ const nav = computed<NavItem[]>(() => [
|
|||||||
{ label: 'Visitenkarten', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: true },
|
{ 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: '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: '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: '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 },
|
{ 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))
|
].filter((i) => i.show))
|
||||||
|
|||||||
@ -20,7 +20,6 @@ const router = createRouter({
|
|||||||
{ path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') },
|
{ path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') },
|
||||||
{ path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') },
|
{ path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') },
|
||||||
{ path: 'card-editor', name: 'card-editor', component: () => import('@/views/CardEditorView.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: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
|
||||||
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
|
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
|
||||||
{ path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') },
|
{ path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') },
|
||||||
|
|||||||
@ -16,10 +16,11 @@ export interface CurrentUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LEVEL: Record<string, number> = {
|
const ROLE_LEVEL: Record<string, number> = {
|
||||||
ROLE_PLATFORM_ADMIN: 3,
|
ROLE_PLATFORM_ADMIN: 4,
|
||||||
ROLE_RESELLER_ADMIN: 2,
|
ROLE_RESELLER_ADMIN: 3,
|
||||||
ROLE_COMPANY_ADMIN: 1,
|
ROLE_COMPANY_ADMIN: 2,
|
||||||
ROLE_EMPLOYEE: 0,
|
ROLE_EMPLOYEE: 1,
|
||||||
|
ROLE_CONTACT: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { list, create, update, remove } from '@/api/resources'
|
import { list, create, update, remove } from '@/api/resources'
|
||||||
import client from '@/api/client'
|
import client from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@ -7,7 +8,14 @@ import Modal from '@/components/Modal.vue'
|
|||||||
|
|
||||||
const GROUP_LABEL: Record<string, string> = {
|
const GROUP_LABEL: Record<string, string> = {
|
||||||
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
|
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
|
||||||
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter',
|
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter', contact: 'Kontakt',
|
||||||
|
}
|
||||||
|
const ROLE_GROUP: [string, string][] = [
|
||||||
|
['ROLE_PLATFORM_ADMIN', 'platform_admin'], ['ROLE_RESELLER_ADMIN', 'reseller_admin'],
|
||||||
|
['ROLE_COMPANY_ADMIN', 'company_admin'], ['ROLE_EMPLOYEE', 'employee'], ['ROLE_CONTACT', 'contact'],
|
||||||
|
]
|
||||||
|
const GROUP_LEVEL: Record<string, number> = {
|
||||||
|
platform_admin: 4, reseller_admin: 3, company_admin: 2, employee: 1, contact: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Employee {
|
interface Employee {
|
||||||
@ -23,6 +31,8 @@ interface Employee {
|
|||||||
mobile: string | null
|
mobile: string | null
|
||||||
status: string
|
status: string
|
||||||
shortCode: string | null
|
shortCode: string | null
|
||||||
|
roles: string[]
|
||||||
|
login: boolean
|
||||||
company: string
|
company: string
|
||||||
location: string | null
|
location: string | null
|
||||||
}
|
}
|
||||||
@ -30,6 +40,7 @@ interface Company { '@id': string; name: string; slug: string }
|
|||||||
interface Location { '@id': string; name: string; company: string }
|
interface Location { '@id': string; name: string; company: string }
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
const PUBLIC_BASE = import.meta.env.VITE_PUBLIC_BASE ?? 'http://localhost:8080'
|
const PUBLIC_BASE = import.meta.env.VITE_PUBLIC_BASE ?? 'http://localhost:8080'
|
||||||
|
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
@ -39,9 +50,21 @@ const loading = ref(true)
|
|||||||
const search = ref('')
|
const search = ref('')
|
||||||
|
|
||||||
const canManageUsers = computed(() => auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin)
|
const canManageUsers = computed(() => auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin)
|
||||||
const usersByEmployee = ref<Record<string, { group: string }>>({})
|
|
||||||
const assignableGroups = ref<string[]>([])
|
const assignableGroups = ref<string[]>([])
|
||||||
const loginForm = ref({ group: 'employee', password: '' })
|
const accessForm = ref({ group: 'contact', password: '' })
|
||||||
|
|
||||||
|
function groupOf(e: Employee): string {
|
||||||
|
const roles = e.roles ?? []
|
||||||
|
for (const [role, group] of ROLE_GROUP) if (roles.includes(role)) return group
|
||||||
|
return 'contact'
|
||||||
|
}
|
||||||
|
function canWorkAs(e: Employee): boolean {
|
||||||
|
return e.login && GROUP_LEVEL[groupOf(e)] < auth.level && e.id !== auth.user?.id
|
||||||
|
}
|
||||||
|
async function workAs(e: Employee) {
|
||||||
|
await auth.impersonate(e.id)
|
||||||
|
router.push('/app')
|
||||||
|
}
|
||||||
|
|
||||||
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c])))
|
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c])))
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
@ -71,30 +94,29 @@ async function load() {
|
|||||||
])
|
])
|
||||||
if (canManageUsers.value) {
|
if (canManageUsers.value) {
|
||||||
try {
|
try {
|
||||||
const [u, g] = await Promise.all([client.get('/users'), client.get('/users/assignable-groups')])
|
assignableGroups.value = (await client.get('/users/assignable-groups')).data.groups
|
||||||
usersByEmployee.value = Object.fromEntries(
|
} catch { /* darf Gruppen nicht abrufen – egal */ }
|
||||||
u.data.member.filter((x: any) => x.employeeId).map((x: any) => [x.employeeId, { group: x.group }]),
|
|
||||||
)
|
|
||||||
assignableGroups.value = g.data.groups
|
|
||||||
} catch { /* Mitarbeiter-Rolle darf /users nicht – egal */ }
|
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function grantLogin(e: Employee) {
|
async function saveAccess(e: Employee) {
|
||||||
if (!e.email) { alert('Der Mitarbeiter braucht eine E-Mail für den Login.'); return }
|
|
||||||
try {
|
try {
|
||||||
await client.post(`/employees/${e.id}/login`, { group: loginForm.value.group, password: loginForm.value.password })
|
const payload: Record<string, unknown> = { group: accessForm.value.group }
|
||||||
loginForm.value = { group: 'employee', password: '' }
|
if (accessForm.value.password) payload.password = accessForm.value.password
|
||||||
|
await client.patch(`/employees/${e.id}/access`, payload)
|
||||||
|
accessForm.value.password = ''
|
||||||
await load()
|
await load()
|
||||||
|
editing.value = employees.value.find((x) => x.id === e.id) ?? null
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err?.response?.data?.error ?? err?.response?.data?.detail ?? 'Login anlegen fehlgeschlagen.')
|
alert(err?.response?.data?.error ?? err?.response?.data?.detail ?? 'Speichern fehlgeschlagen.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function removeLogin(e: Employee) {
|
async function removeLogin(e: Employee) {
|
||||||
if (!usersByEmployee.value[e.id] || !confirm('Login für diesen Mitarbeiter entfernen?')) return
|
if (!confirm('Login entziehen? Der Mitarbeiter wird wieder reiner Kontakt.')) return
|
||||||
await client.delete(`/employees/${e.id}/login`)
|
await client.delete(`/employees/${e.id}/login`)
|
||||||
await load()
|
await load()
|
||||||
|
editing.value = employees.value.find((x) => x.id === e.id) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Anlegen / Bearbeiten ---
|
// --- Anlegen / Bearbeiten ---
|
||||||
@ -131,6 +153,7 @@ function openEdit(e: Employee) {
|
|||||||
email: e.email ?? '', phone: e.phone ?? '', mobile: e.mobile ?? '',
|
email: e.email ?? '', phone: e.phone ?? '', mobile: e.mobile ?? '',
|
||||||
company: e.company, location: e.location ?? '',
|
company: e.company, location: e.location ?? '',
|
||||||
}
|
}
|
||||||
|
accessForm.value = { group: groupOf(e), password: '' }
|
||||||
error.value = ''
|
error.value = ''
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
}
|
}
|
||||||
@ -195,7 +218,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
<table class="tbl">
|
<table class="tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Position</th><th>Standort</th><th>Status</th><th></th></tr>
|
<tr><th>Name</th><th>Position</th><th>Rechtegruppe</th><th>Standort</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="loading"><td colspan="5" class="empty">Lädt…</td></tr>
|
<tr v-if="loading"><td colspan="5" class="empty">Lädt…</td></tr>
|
||||||
@ -211,9 +234,13 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ e.position ?? '–' }}</td>
|
<td>{{ e.position ?? '–' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-role">{{ GROUP_LABEL[groupOf(e)] }}</span>
|
||||||
|
<span v-if="e.login" class="muted small" title="Login aktiv"> · 🔑</span>
|
||||||
|
</td>
|
||||||
<td class="muted">{{ locName(e.location) }}</td>
|
<td class="muted">{{ locName(e.location) }}</td>
|
||||||
<td><span class="badge" :class="e.status === 'active' ? 'badge-active' : 'badge-inactive'">{{ e.status }}</span></td>
|
|
||||||
<td class="right">
|
<td class="right">
|
||||||
|
<button v-if="canWorkAs(e)" class="btn btn-soft btn-sm" @click="workAs(e)">Arbeiten als</button>
|
||||||
<a class="btn btn-soft btn-sm" :href="profileUrl(e)" target="_blank" rel="noopener">Profil ↗</a>
|
<a class="btn btn-soft btn-sm" :href="profileUrl(e)" target="_blank" rel="noopener">Profil ↗</a>
|
||||||
<button class="btn btn-ghost btn-sm" @click="openEdit(e)">Bearbeiten</button>
|
<button class="btn btn-ghost btn-sm" @click="openEdit(e)">Bearbeiten</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="del(e)">Löschen</button>
|
<button class="btn btn-ghost btn-sm" @click="del(e)">Löschen</button>
|
||||||
@ -263,25 +290,24 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editing && canManageUsers" class="login-box">
|
<div v-if="editing && canManageUsers" class="login-box">
|
||||||
<div class="nfc__label">Zugang / Rechtegruppe</div>
|
<div class="nfc__label">Rechtegruppe & Login</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="grid2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Rechtegruppe</label>
|
<label>Rechtegruppe</label>
|
||||||
<select class="input" v-model="loginForm.group">
|
<select class="input" v-model="accessForm.group">
|
||||||
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
|
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Passwort</label><input class="input" type="password" v-model="loginForm.password" minlength="6" /></div>
|
<div class="field">
|
||||||
|
<label>{{ editing.login ? 'Neues Passwort (optional)' : 'Passwort (für Login)' }}</label>
|
||||||
|
<input class="input" type="password" v-model="accessForm.password" minlength="6" placeholder="leer = kein Login" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="access-actions">
|
||||||
|
<button type="button" class="btn btn-soft btn-sm" @click="saveAccess(editing)">Rechtegruppe übernehmen</button>
|
||||||
|
<button v-if="editing.login" type="button" class="btn btn-ghost btn-sm" @click="removeLogin(editing)">Login entziehen</button>
|
||||||
|
<span class="muted small">Aktuell: {{ GROUP_LABEL[groupOf(editing)] }} · Login {{ editing.login ? 'aktiv' : 'inaktiv' }}</span>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@ -318,4 +344,6 @@ onMounted(load)
|
|||||||
.nfc__row code { font-size: .82rem; word-break: break-all; }
|
.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 { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .6rem; }
|
||||||
.login-box .grid2 { margin-bottom: .4rem; }
|
.login-box .grid2 { margin-bottom: .4rem; }
|
||||||
|
.access-actions { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import client from '@/api/client'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
interface LoginRow {
|
|
||||||
employeeId: string; email: string; name: string; group: string
|
|
||||||
company: { id: string; name: string } | null
|
|
||||||
reseller: { id: string; name: string } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const GROUP_LABEL: Record<string, string> = {
|
|
||||||
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
|
|
||||||
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter',
|
|
||||||
}
|
|
||||||
const GROUP_LEVEL: Record<string, number> = {
|
|
||||||
platform_admin: 3, reseller_admin: 2, company_admin: 1, employee: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const rows = ref<LoginRow[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
function canWorkAs(r: LoginRow) {
|
|
||||||
return GROUP_LEVEL[r.group] < auth.level && r.employeeId !== auth.user?.id
|
|
||||||
}
|
|
||||||
async function workAs(r: LoginRow) {
|
|
||||||
await auth.impersonate(r.employeeId)
|
|
||||||
router.push('/app')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
rows.value = (await client.get('/users')).data.member
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
async function revoke(r: LoginRow) {
|
|
||||||
if (!confirm(`Login von „${r.email}" entfernen?`)) return
|
|
||||||
try { await client.delete(`/employees/${r.employeeId}/login`); await load() }
|
|
||||||
catch { alert('Entfernen fehlgeschlagen.') }
|
|
||||||
}
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section>
|
|
||||||
<div class="page-head">
|
|
||||||
<h1>Benutzer / Logins</h1>
|
|
||||||
<p class="muted">Übersicht aller Mitarbeiter mit Login. Vergabe erfolgt über die Mitarbeiterliste (Rechtegruppe).</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<table class="tbl">
|
|
||||||
<thead><tr><th>Login</th><th>Name</th><th>Rechtegruppe</th><th>Zuordnung</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="loading"><td colspan="5" class="empty">Lädt…</td></tr>
|
|
||||||
<tr v-else-if="!rows.length"><td colspan="5" class="empty">Noch keine Logins.</td></tr>
|
|
||||||
<tr v-for="r in rows" :key="r.employeeId">
|
|
||||||
<td><strong>{{ r.email }}</strong></td>
|
|
||||||
<td>{{ r.name }}</td>
|
|
||||||
<td><span class="badge badge-role">{{ GROUP_LABEL[r.group] ?? r.group }}</span></td>
|
|
||||||
<td class="muted">{{ r.company?.name ?? r.reseller?.name ?? 'Plattform' }}</td>
|
|
||||||
<td class="right">
|
|
||||||
<button v-if="canWorkAs(r)" class="btn btn-soft btn-sm" @click="workAs(r)">Arbeiten als</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" @click="revoke(r)">Login entfernen</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page-head { 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); }
|
|
||||||
</style>
|
|
||||||
Loading…
Reference in New Issue
Block a user