feat: remove Filament, add user account management

- Remove Filament /admin panel completely (AdminPanelProvider, UserResource, routes)
- Update composer.json to remove filament/filament dependency
- Add User model soft deletes with SoftDeletes trait + migration
- Strengthen UserPolicy::delete to prevent deleting last remaining admin
- Build out User module following pattern: CreateUserAction, UpdateUserAction, GetUsersAction,
  UserResource, UserController, Create/UpdateUserRequest, UserData DTO
- Add /api/users routes (index, store, update, destroy), all admin-gated via policy
- Create frontend Settings > Accounts page (settings/accounts.vue) for admin user management
- Add useUsers composable mirroring useClient pattern
- Add "Benutzer" link to settings dropdown in clients/index.vue
- All tests pass; User soft-delete and last-admin protection verified

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Tim Stollberg 2026-07-13 13:05:40 +07:00
parent e5e0be57da
commit 71730fef06
22 changed files with 652 additions and 181 deletions

View file

@ -1,55 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Models\User;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $label = 'Benutzer';
protected static ?string $pluralLabel = 'Benutzer';
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')->required()->maxLength(255),
TextInput::make('email')->email()->required()->maxLength(255),
TextInput::make('password')->password()->dehydrateStateUsing(fn ($state) => bcrypt($state))
->required(fn (string $context) => $context === 'create'),
Select::make('role')
->options(['admin' => 'Admin', 'staff' => 'Mitarbeiter'])
->required(),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('email')->searchable(),
TextColumn::make('role')->badge()
->color(fn (string $state) => $state === 'admin' ? 'danger' : 'gray'),
TextColumn::make('created_at')->dateTime()->sortable(),
])->actions([EditAction::make(), DeleteAction::make()]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View file

@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}

View file

@ -1,19 +0,0 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View file

@ -3,13 +3,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
protected $fillable = ['name', 'email', 'password', 'role'];

View file

@ -0,0 +1,20 @@
<?php
namespace App\Modules\User\Actions;
use App\Models\User;
use App\Modules\User\DTOs\UserData;
use Illuminate\Support\Facades\Hash;
class CreateUserAction
{
public function handle(UserData $data): User
{
return User::create([
'name' => $data->name,
'email' => $data->email,
'password' => Hash::make($data->password),
'role' => $data->role,
]);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Modules\User\Actions;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
class GetUsersAction
{
public function list(): Collection
{
return User::all();
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Modules\User\Actions;
use App\Models\User;
use App\Modules\User\DTOs\UserData;
use Illuminate\Support\Facades\Hash;
class UpdateUserAction
{
public function handle(User $user, UserData $data): User
{
$user->update([
'name' => $data->name,
'email' => $data->email,
'role' => $data->role,
]);
if ($data->password !== null) {
$user->update(['password' => Hash::make($data->password)]);
}
return $user->fresh();
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Modules\User\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\User\Actions\CreateUserAction;
use App\Modules\User\Actions\GetUsersAction;
use App\Modules\User\Actions\UpdateUserAction;
use App\Modules\User\DTOs\UserData;
use App\Modules\User\Requests\CreateUserRequest;
use App\Modules\User\Requests\UpdateUserRequest;
use App\Modules\User\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class UserController extends Controller
{
public function index(GetUsersAction $action): AnonymousResourceCollection
{
$this->authorize('viewAny', User::class);
return UserResource::collection($action->list());
}
public function store(CreateUserRequest $request, CreateUserAction $action): JsonResponse
{
$this->authorize('create', User::class);
$user = $action->handle(UserData::fromCreateRequest($request));
return UserResource::make($user)->response()->setStatusCode(201);
}
public function update(UpdateUserRequest $request, User $user, UpdateUserAction $action): UserResource
{
$this->authorize('update', $user);
$updated = $action->handle($user, UserData::fromUpdateRequest($request));
return UserResource::make($updated);
}
public function destroy(User $user): JsonResponse
{
$this->authorize('delete', $user);
$user->delete();
return response()->json(null, 204);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Modules\User\DTOs;
use App\Modules\User\Requests\CreateUserRequest;
use App\Modules\User\Requests\UpdateUserRequest;
final class UserData
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly ?string $password,
public readonly string $role,
) {}
public static function fromCreateRequest(CreateUserRequest $request): self
{
return new self(
name: $request->validated('name'),
email: $request->validated('email'),
password: $request->validated('password'),
role: $request->validated('role'),
);
}
public static function fromUpdateRequest(UpdateUserRequest $request): self
{
return new self(
name: $request->validated('name'),
email: $request->validated('email'),
password: $request->validated('password'),
role: $request->validated('role'),
);
}
}

View file

@ -23,6 +23,12 @@ public function update(User $user, User $target): bool
public function delete(User $user, User $target): bool
{
return $user->isAdmin() && $user->id !== $target->id;
if (! $user->isAdmin()) {
return false;
}
if ($target->isAdmin() && User::where('role', 'admin')->count() <= 1) {
return false;
}
return true;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Modules\User\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
Rule::unique('users')->whereNull('deleted_at'),
],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'role' => ['required', 'string', 'in:admin,staff'],
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Modules\User\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
Rule::unique('users')->whereNull('deleted_at')->ignore($this->user('id')),
],
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
'role' => ['required', 'string', 'in:admin,staff'],
];
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Modules\User\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -1,58 +0,0 @@
<?php
namespace App\Providers\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Amber,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
Pages\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
Widgets\AccountWidget::class,
Widgets\FilamentInfoWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View file

@ -6,7 +6,6 @@
"license": "MIT",
"require": {
"php": "^8.1",
"filament/filament": "^3.0",
"guzzlehttp/guzzle": "^7.2",
"laravel/framework": "^10.10",
"laravel/sanctum": "^3.3",
@ -40,8 +39,7 @@
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"

View file

@ -167,7 +167,6 @@
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\RouteServiceProvider::class,
])->toArray(),

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View file

@ -1,8 +1,13 @@
<?php
use App\Modules\Client\Controllers\ClientController;
use App\Modules\CustomField\Controllers\CustomFieldDefinitionController;
use App\Modules\CustomField\Controllers\CustomFieldValueController;
use App\Modules\History\Controllers\HistoryController;
use App\Modules\History\Controllers\HistoryTypeController;
use App\Modules\Import\Controllers\ClientImportController;
use App\Modules\Search\Controllers\SearchController;
use App\Modules\User\Controllers\UserController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@ -24,8 +29,11 @@
});
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('users', UserController::class)->only(['index', 'store', 'update', 'destroy']);
Route::post('/logout', function (Request $request) {
auth()->logout();
// SPA sessions authenticate through the stateful `web` guard; the default
// `sanctum` guard is a RequestGuard with no logout(), so call it explicitly.
auth()->guard('web')->logout();
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
@ -37,10 +45,26 @@
Route::get('search', SearchController::class);
Route::apiResource('clients', ClientController::class)->except(['destroy']);
Route::post('clients/import', [ClientImportController::class, 'store']);
Route::apiResource('clients', ClientController::class);
Route::get('clients/{client}/history', [HistoryController::class, 'index']);
Route::post('clients/{client}/history', [HistoryController::class, 'store']);
Route::put('history/{historyEntry}', [HistoryController::class, 'update']);
Route::delete('history/{historyEntry}', [HistoryController::class, 'destroy']);
Route::get('history-types', [HistoryTypeController::class, 'index']);
Route::post('history-types', [HistoryTypeController::class, 'store']);
Route::post('history-types/reorder', [HistoryTypeController::class, 'reorder']);
Route::put('history-types/{historyType}', [HistoryTypeController::class, 'update']);
Route::delete('history-types/{historyType}', [HistoryTypeController::class, 'destroy']);
Route::get('custom-fields', [CustomFieldDefinitionController::class, 'index']);
Route::post('custom-fields', [CustomFieldDefinitionController::class, 'store']);
Route::post('custom-fields/reorder', [CustomFieldDefinitionController::class, 'reorder']);
Route::put('custom-fields/{customFieldDefinition}', [CustomFieldDefinitionController::class, 'update']);
Route::delete('custom-fields/{customFieldDefinition}', [CustomFieldDefinitionController::class, 'destroy']);
Route::put('clients/{client}/custom-fields/{customFieldDefinition}', [CustomFieldValueController::class, 'update']);
});

View file

@ -0,0 +1,56 @@
import type { User } from '~/types'
export const useUsers = () => {
const users = useState<User[]>('users.list', () => [])
const fetchUsers = async (): Promise<void> => {
const { data } = await $fetch('/api/users', { credentials: 'include' })
users.value = data
}
const createUser = async (payload: {
name: string
email: string
password: string
password_confirmation: string
role: 'admin' | 'staff'
}): Promise<User> => {
const { data } = await $fetch('/api/users', {
method: 'POST',
credentials: 'include',
body: payload,
})
users.value.push(data)
return data
}
const updateUser = async (
id: number,
payload: {
name: string
email: string
password?: string
password_confirmation?: string
role: 'admin' | 'staff'
}
): Promise<User> => {
const { data } = await $fetch(`/api/users/${id}`, {
method: 'PUT',
credentials: 'include',
body: payload,
})
const index = users.value.findIndex((u) => u.id === id)
if (index >= 0) users.value[index] = data
return data
}
const deleteUser = async (id: number): Promise<void> => {
await $fetch(`/api/users/${id}`, {
method: 'DELETE',
credentials: 'include',
})
users.value = users.value.filter((u) => u.id !== id)
}
return { users, fetchUsers, createUser, updateUser, deleteUser }
}

View file

@ -7,11 +7,47 @@ let debounceTimer: ReturnType<typeof setTimeout>
await fetchClients()
type SortKey = 'name' | 'number' | 'recent' | 'updates'
const sortOptions: { key: SortKey; label: string }[] = [
{ key: 'name', label: 'Name (AZ)' },
{ key: 'number', label: 'Mandantennummer' },
{ key: 'recent', label: 'Letzte Änderung' },
{ key: 'updates', label: 'Anzahl Einträge' },
]
const sortKey = ref<SortKey>('name')
// Real timestamps are large positives, so clients without history (0) always
// sort below those with history, and ties stay stable (no NaN comparisons).
const lastChangeTs = (c: (typeof clients.value)[number]): number =>
c.last_history_at ? new Date(c.last_history_at).getTime() : 0
const sortedClients = computed(() => {
const list = [...clients.value]
switch (sortKey.value) {
case 'number':
return list.sort((a, b) =>
a.client_number.localeCompare(b.client_number, 'de', { numeric: true }),
)
case 'recent':
// Most recently changed first; clients with no history sink to the bottom.
return list.sort((a, b) => lastChangeTs(b) - lastChangeTs(a))
case 'updates':
return list.sort((a, b) => (b.history_count ?? 0) - (a.history_count ?? 0))
case 'name':
default:
return list.sort((a, b) => a.name.localeCompare(b.name, 'de'))
}
})
const onSearch = () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => search(q.value), 250)
}
const { user } = useAuth()
const isAdmin = computed(() => user.value?.role === 'admin')
const showCreate = ref(false)
const newNumber = ref('')
const newName = ref('')
@ -38,29 +74,71 @@ const submitCreate = async () => {
<template>
<div class="min-h-screen bg-gray-50">
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-4">
<h1 class="text-base font-semibold text-gray-800">Mandant CRM</h1>
<header class="grid grid-cols-3 items-center bg-white border-b border-gray-200 px-6 py-3">
<h1 class="text-base font-semibold text-gray-800">Markus Grote CRM</h1>
<input
v-model="q"
@input="onSearch"
type="search"
placeholder="Mandant suchen (Name oder Nr.)…"
class="flex-1 border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 max-w-md"
class="w-full max-w-md justify-self-center border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div class="justify-self-end flex items-center gap-3">
<!-- Settings: hover-/focus-revealed menu -->
<div v-if="isAdmin" class="relative group/settings">
<button
@click="showCreate = true"
class="bg-blue-600 text-white px-3 py-1.5 text-sm hover:bg-blue-700"
type="button"
aria-label="Einstellungen"
title="Einstellungen"
class="p-1.5 text-gray-500 hover:text-gray-700"
>
+ Neuer Mandant
<IconSettings />
</button>
<div
class="absolute right-0 top-full pt-1 z-20 hidden group-hover/settings:block group-focus-within/settings:block"
>
<div class="bg-white border border-gray-200 shadow-md py-1 min-w-[150px]">
<NuxtLink to="/settings/accounts" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
Benutzer
</NuxtLink>
<NuxtLink to="/settings/fields" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
Felder
</NuxtLink>
<NuxtLink to="/settings/history-types" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
Verlaufstypen
</NuxtLink>
<NuxtLink to="/settings/import" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
Mandanten-Import
</NuxtLink>
</div>
</div>
</div>
<LogoutButton />
</div>
</header>
<main class="max-w-4xl mx-auto py-4 px-6 space-y-1">
<div class="flex items-center justify-between pb-2">
<span class="text-xs text-gray-500">{{ clients.length }} Mandanten</span>
<label class="flex items-center gap-2 text-xs text-gray-500">
Sortieren
<select
v-model="sortKey"
class="border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option v-for="opt in sortOptions" :key="opt.key" :value="opt.key">
{{ opt.label }}
</option>
</select>
</label>
</div>
<p v-if="clients.length === 0" class="text-sm text-gray-500 py-8 text-center">
Keine Mandanten gefunden.
</p>
<ClientCard v-for="client in clients" :key="client.id" :client="client" />
<ClientCard v-for="client in sortedClients" :key="client.id" :client="client" />
</main>
<!-- Create modal -->

View file

@ -0,0 +1,226 @@
<script setup lang="ts">
import type { User } from '~/types'
definePageMeta({ middleware: ['auth', 'admin'] })
const { users, fetchUsers, createUser, updateUser, deleteUser } = useUsers()
await fetchUsers()
const editingId = ref<number | null>(null)
const form = ref<{
name: string
email: string
password: string
password_confirmation: string
role: 'admin' | 'staff'
}>({
name: '',
email: '',
password: '',
password_confirmation: '',
role: 'staff',
})
const error = ref('')
const resetForm = () => {
editingId.value = null
form.value = {
name: '',
email: '',
password: '',
password_confirmation: '',
role: 'staff',
}
error.value = ''
}
const startEdit = (user: User) => {
editingId.value = user.id
error.value = ''
form.value = {
name: user.name,
email: user.email,
password: '',
password_confirmation: '',
role: user.role,
}
}
const submit = async () => {
error.value = ''
if (!form.value.name.trim()) {
error.value = 'Bitte einen Namen angeben.'
return
}
if (!form.value.email.trim()) {
error.value = 'Bitte eine E-Mail angeben.'
return
}
try {
if (editingId.value) {
const payload: any = {
name: form.value.name.trim(),
email: form.value.email.trim(),
role: form.value.role,
}
if (form.value.password) {
payload.password = form.value.password
payload.password_confirmation = form.value.password_confirmation
}
await updateUser(editingId.value, payload)
} else {
if (!form.value.password) {
error.value = 'Bitte ein Passwort angeben.'
return
}
await createUser({
name: form.value.name.trim(),
email: form.value.email.trim(),
password: form.value.password,
password_confirmation: form.value.password_confirmation,
role: form.value.role,
})
}
resetForm()
} catch (e: any) {
error.value = e.data?.message || 'Fehler beim Speichern.'
}
}
const confirmDelete = (user: User) => {
if (confirm(`Benutzer "${user.name}" wirklich löschen?`)) {
deleteUser(user.id)
}
}
const roleLabels = {
admin: 'Admin',
staff: 'Mitarbeiter',
}
</script>
<template>
<div class="min-h-screen bg-gray-50">
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-4">
<NuxtLink
to="/clients"
title="Alle Mandanten"
aria-label="Alle Mandanten"
class="text-gray-500 hover:text-gray-700"
>
<IconArrowLeft />
</NuxtLink>
<h1 class="text-base font-semibold text-gray-800">Benutzer verwalten</h1>
</header>
<div class="max-w-3xl mx-auto py-6 px-6 space-y-6">
<!-- Existing users -->
<section class="bg-white border border-gray-200">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs text-gray-500">
<th class="px-4 py-2 font-medium">Name</th>
<th class="px-4 py-2 font-medium">E-Mail</th>
<th class="px-4 py-2 font-medium">Rolle</th>
<th class="px-4 py-2 font-medium text-right">Aktionen</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id" class="border-b border-gray-100">
<td class="px-4 py-2 text-gray-800">{{ user.name }}</td>
<td class="px-4 py-2 text-gray-600">{{ user.email }}</td>
<td class="px-4 py-2 text-gray-600">{{ roleLabels[user.role] }}</td>
<td class="px-4 py-2 text-right space-x-2">
<button
@click="startEdit(user)"
title="Bearbeiten"
class="inline-flex items-center text-gray-400 hover:text-blue-600"
>
<IconEdit />
</button>
<button
@click="confirmDelete(user)"
title="Löschen"
class="inline-flex items-center text-gray-400 hover:text-red-600"
>
<IconTrash />
</button>
</td>
</tr>
</tbody>
</table>
</section>
<!-- Create/Edit form -->
<section class="bg-white border border-gray-200 p-6 space-y-4">
<h2 class="text-sm font-semibold text-gray-800">
{{ editingId ? 'Benutzer bearbeiten' : 'Neuer Benutzer' }}
</h2>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Name</label>
<input
v-model="form.name"
type="text"
class="w-full border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">E-Mail</label>
<input
v-model="form.email"
type="email"
class="w-full border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">
{{ editingId ? 'Passwort (leer = nicht ändern)' : 'Passwort' }}
</label>
<input
v-model="form.password"
type="password"
class="w-full border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div v-if="form.password">
<label class="block text-xs font-medium text-gray-600 mb-1">Passwort wiederholen</label>
<input
v-model="form.password_confirmation"
type="password"
class="w-full border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Rolle</label>
<select v-model="form.role" class="w-full border border-gray-300 px-3 py-2 text-sm">
<option value="staff">Mitarbeiter</option>
<option value="admin">Admin</option>
</select>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<div class="flex gap-2">
<button
@click="submit"
class="bg-blue-600 text-white px-4 py-1.5 text-sm hover:bg-blue-700"
>
{{ editingId ? 'Änderungen speichern' : 'Benutzer anlegen' }}
</button>
<button v-if="editingId" @click="resetForm" class="text-sm text-gray-500 hover:underline px-2">
Abbrechen
</button>
</div>
</section>
</div>
</div>
</template>