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:
Thomas Peterson 2026-06-01 21:44:57 +02:00
parent ae9936586b
commit 2dc40c6ea5
9 changed files with 113 additions and 154 deletions

View File

@ -41,9 +41,10 @@ security:
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy:
ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE]
ROLE_RESELLER_ADMIN: [ROLE_COMPANY_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_EMPLOYEE, ROLE_CONTACT]
ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE, ROLE_CONTACT]
ROLE_EMPLOYEE: [ROLE_CONTACT]
when@test:
security:

View File

@ -66,23 +66,27 @@ final class UserAdminController
return new JsonResponse(['member' => $rows, 'totalItems' => count($rows)]);
}
#[Route('/api/employees/{id}/login', name: 'employee_login_grant', methods: ['POST'])]
public function grant(string $id, Request $request): JsonResponse
/** Rechtegruppe setzen (+ optional Login/Passwort) — Hochstufen via Rolle. */
#[Route('/api/employees/{id}/access', name: 'employee_access', methods: ['PATCH'])]
public function setAccess(string $id, Request $request): JsonResponse
{
$employee = $this->employee($id);
$d = json_decode($request->getContent(), true) ?? [];
$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());
$employee->setRoles([$this->roles->roleForGroup($group)]);
$employee->setLoginEmail($loginEmail)
->setRoles([$this->roles->roleForGroup($group)]);
$employee->setPassword($this->hasher->hashPassword($employee, $password));
// Passwort optional: setzt/aktiviert das Login
$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));
}
try {
$this->em->flush();
@ -90,9 +94,10 @@ final class UserAdminController
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'])]
public function revoke(string $id): JsonResponse
{
@ -100,7 +105,7 @@ final class UserAdminController
if ($this->tenant->getEmployee() === $employee) {
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();
return new JsonResponse(null, 204);

View File

@ -10,6 +10,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Ignore;
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_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN';
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\Column(type: UuidType::NAME, unique: true)]
@ -102,9 +105,9 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
#[ORM\Column(nullable: true)]
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')]
private array $roles = [];
private array $roles = [self::ROLE_CONTACT];
/** @var Collection<int, ContactLink> */
#[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;
}
#[Ignore]
public function getUserIdentifier(): string
{
return (string) $this->loginEmail;
@ -378,6 +382,7 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
return $this;
}
#[Ignore]
public function getPassword(): ?string
{
return $this->password;

View File

@ -16,12 +16,16 @@ final class RoleService
{
/** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */
private const GROUPS = [
'platform_admin' => ['role' => Employee::ROLE_PLATFORM_ADMIN, 'level' => 3],
'reseller_admin' => ['role' => Employee::ROLE_RESELLER_ADMIN, 'level' => 2],
'company_admin' => ['role' => Employee::ROLE_COMPANY_ADMIN, 'level' => 1],
'employee' => ['role' => Employee::ROLE_EMPLOYEE, 'level' => 0],
'platform_admin' => ['role' => Employee::ROLE_PLATFORM_ADMIN, 'level' => 4],
'reseller_admin' => ['role' => Employee::ROLE_RESELLER_ADMIN, 'level' => 3],
'company_admin' => ['role' => Employee::ROLE_COMPANY_ADMIN, 'level' => 2],
'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)
{
}
@ -40,12 +44,15 @@ final class RoleService
public function actorLevel(): int
{
if ($this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN)) {
return 3;
return 4;
}
if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) {
return 2;
return 3;
}
if ($this->security->isGranted(Employee::ROLE_COMPANY_ADMIN)) {
return 2;
}
if ($this->security->isGranted(Employee::ROLE_EMPLOYEE)) {
return 1;
}
@ -90,12 +97,12 @@ final class RoleService
if ($targetLevel > $actorLevel) {
throw new AccessDeniedHttpException('Keine Berechtigung, diese Rechtegruppe zu vergeben.');
}
if ($actorLevel < 1) {
if ($actorLevel < self::MANAGE_LEVEL) {
throw new AccessDeniedHttpException('Keine Berechtigung zur Benutzerverwaltung.');
}
// Plattform-Admin: keine Mandantengrenze
if ($actorLevel >= 3) {
if ($actorLevel >= 4) {
return;
}
@ -103,7 +110,7 @@ final class RoleService
$actor = $this->security->getUser();
// Regel 2: nur im eigenen Mandanten-Teilbaum
if ($actorLevel === 2) { // Reseller-Admin
if ($actorLevel === 3) { // Reseller-Admin
$actorReseller = $actor->getReseller();
$effectiveReseller = $targetCompany?->getReseller() ?? $targetReseller;
if (null === $actorReseller || null === $effectiveReseller
@ -114,7 +121,7 @@ final class RoleService
return;
}
if ($actorLevel === 1) { // Firmen-Admin
if ($actorLevel === 2) { // Firmen-Admin
$actorCompany = $actor->getCompany();
if (null === $actorCompany || null === $targetCompany
|| !$targetCompany->getId()->equals($actorCompany->getId())) {

View File

@ -16,7 +16,6 @@ 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,7 +20,6 @@ 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

@ -16,10 +16,11 @@ export interface CurrentUser {
}
const ROLE_LEVEL: Record<string, number> = {
ROLE_PLATFORM_ADMIN: 3,
ROLE_RESELLER_ADMIN: 2,
ROLE_COMPANY_ADMIN: 1,
ROLE_EMPLOYEE: 0,
ROLE_PLATFORM_ADMIN: 4,
ROLE_RESELLER_ADMIN: 3,
ROLE_COMPANY_ADMIN: 2,
ROLE_EMPLOYEE: 1,
ROLE_CONTACT: 0,
}
export const useAuthStore = defineStore('auth', () => {

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { list, create, update, remove } from '@/api/resources'
import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
@ -7,7 +8,14 @@ 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',
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 {
@ -23,6 +31,8 @@ interface Employee {
mobile: string | null
status: string
shortCode: string | null
roles: string[]
login: boolean
company: string
location: string | null
}
@ -30,6 +40,7 @@ interface Company { '@id': string; name: string; slug: string }
interface Location { '@id': string; name: string; company: string }
const auth = useAuthStore()
const router = useRouter()
const PUBLIC_BASE = import.meta.env.VITE_PUBLIC_BASE ?? 'http://localhost:8080'
const employees = ref<Employee[]>([])
@ -39,9 +50,21 @@ const loading = ref(true)
const search = ref('')
const canManageUsers = computed(() => auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin)
const usersByEmployee = ref<Record<string, { group: 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 filtered = computed(() => {
@ -71,30 +94,29 @@ async function load() {
])
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, { group: x.group }]),
)
assignableGroups.value = g.data.groups
} catch { /* Mitarbeiter-Rolle darf /users nicht egal */ }
assignableGroups.value = (await client.get('/users/assignable-groups')).data.groups
} catch { /* darf Gruppen nicht abrufen egal */ }
}
loading.value = false
}
async function grantLogin(e: Employee) {
if (!e.email) { alert('Der Mitarbeiter braucht eine E-Mail für den Login.'); return }
async function saveAccess(e: Employee) {
try {
await client.post(`/employees/${e.id}/login`, { group: loginForm.value.group, password: loginForm.value.password })
loginForm.value = { group: 'employee', password: '' }
const payload: Record<string, unknown> = { group: accessForm.value.group }
if (accessForm.value.password) payload.password = accessForm.value.password
await client.patch(`/employees/${e.id}/access`, payload)
accessForm.value.password = ''
await load()
editing.value = employees.value.find((x) => x.id === e.id) ?? null
} 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) {
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 load()
editing.value = employees.value.find((x) => x.id === e.id) ?? null
}
// --- Anlegen / Bearbeiten ---
@ -131,6 +153,7 @@ function openEdit(e: Employee) {
email: e.email ?? '', phone: e.phone ?? '', mobile: e.mobile ?? '',
company: e.company, location: e.location ?? '',
}
accessForm.value = { group: groupOf(e), password: '' }
error.value = ''
showForm.value = true
}
@ -195,7 +218,7 @@ onMounted(load)
</div>
<table class="tbl">
<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>
<tbody>
<tr v-if="loading"><td colspan="5" class="empty">Lädt</td></tr>
@ -211,9 +234,13 @@ onMounted(load)
</div>
</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><span class="badge" :class="e.status === 'active' ? 'badge-active' : 'badge-inactive'">{{ e.status }}</span></td>
<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>
<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>
@ -263,25 +290,24 @@ onMounted(load)
</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 class="nfc__label">Rechtegruppe & Login</div>
<div class="grid2">
<div class="field">
<label>Rechtegruppe</label>
<select class="input" v-model="accessForm.group">
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
</select>
</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 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>
<p v-if="error" class="error">{{ error }}</p>
@ -318,4 +344,6 @@ onMounted(load)
.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; }
.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>

View File

@ -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>