feat(admin): Benutzerverwaltung mit CRUD und Rollenzuweisung

- Admin-Modul unter /admin/users (nur role:admin)
- Benutzer anlegen, bearbeiten, löschen
- Rollenzuweisung im Formular
- Navigationslink für Admins
- CHANGELOG v0.3.0

Version: 0.3.0
This commit is contained in:
2026-06-27 17:24:49 +02:00
parent a9f86b2551
commit f10f308392
7 changed files with 397 additions and 5 deletions
+27 -5
View File
@@ -7,13 +7,33 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
---
## [0.3.0] - 2026-06-27
### Added ### Added
- Laravel-Projekt aufgesetzt mit MariaDB-Anbindung - Admin-Modul: komplette Benutzerverwaltung unter `/admin/users`
- Authentifizierung via Laravel Breeze (Login, Registrierung, Passwort-Reset) - Benutzer anlegen, bearbeiten, löschen über Web-Oberfläche
- Rollen-basierte Zugriffskontrolle (RBAC) via Spatie Laravel Permission - Rollenzuweisung direkt im Formular (admin / manager / user)
- Navigationslink „Benutzerverwaltung" nur für Admins sichtbar (`@role('admin')`)
- Gefahrenzone im Bearbeiten-Formular für sicheres Löschen
- Schutz: eigener Account kann nicht gelöscht werden
### Security
- Admin-Routen mit Middleware `role:admin` geschützt
---
## [0.2.0] - 2026-06-27
### Added
- Laravel 13 Projektstruktur
- Authentifizierung via Laravel Breeze (Blade)
- RBAC via Spatie Permission v8 (admin/manager/user)
- MariaDB-Anbindung konfiguriert
- Rollen: `admin`, `manager`, `user` - Rollen: `admin`, `manager`, `user`
- Permissions: `user.*`, `role.*`, `network.*` - Permissions: `user.*`, `role.*`, `network.*`
- Standard-Admin-Account beim Seeden angelegt - Standard-Admin-Account: admin@mms-systemservice.de
- Docker-Umgebung: Gitea, MariaDB, phpMyAdmin - Docker-Umgebung: Gitea, MariaDB, phpMyAdmin
--- ---
@@ -26,5 +46,7 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
- Grundlegende PHP-Projektstruktur (public/, src/, config/) - Grundlegende PHP-Projektstruktur (public/, src/, config/)
- composer.json, .gitignore, README.md - composer.json, .gitignore, README.md
[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.1.0...HEAD [Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.3.0...HEAD
[0.3.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.2.0...v0.3.0
[0.2.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.1.0...v0.2.0
[0.1.0]: http://localhost:3000/admin/Network-MGMT/releases/tag/v0.1.0 [0.1.0]: http://localhost:3000/admin/Network-MGMT/releases/tag/v0.1.0
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
use Spatie\Permission\Models\Role;
class UserController extends Controller
{
public function index(): View
{
$users = User::with('roles')->orderBy('name')->paginate(20);
return view('admin.users.index', compact('users'));
}
public function create(): View
{
$roles = Role::orderBy('name')->get();
return view('admin.users.create', compact('roles'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::min(8)->mixedCase()->numbers()],
'role' => ['required', 'string', Rule::exists('roles', 'name')],
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$user->assignRole($validated['role']);
return redirect()
->route('admin.users.index')
->with('success', "Benutzer „{$user->name}" wurde angelegt.");
}
public function edit(User $user): View
{
$roles = Role::orderBy('name')->get();
return view('admin.users.edit', compact('user', 'roles'));
}
public function update(Request $request, User $user): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'password' => ['nullable', Password::min(8)->mixedCase()->numbers()],
'role' => ['required', 'string', Rule::exists('roles', 'name')],
]);
$user->update([
'name' => $validated['name'],
'email' => $validated['email'],
]);
if (!empty($validated['password'])) {
$user->update(['password' => Hash::make($validated['password'])]);
}
$user->syncRoles([$validated['role']]);
return redirect()
->route('admin.users.index')
->with('success', "Benutzer „{$user->name}" wurde aktualisiert.");
}
public function destroy(User $user): RedirectResponse
{
if ($user->id === auth()->id()) {
return redirect()
->route('admin.users.index')
->with('error', 'Du kannst deinen eigenen Account nicht löschen.');
}
$name = $user->name;
$user->delete();
return redirect()
->route('admin.users.index')
->with('success', "Benutzer „{$name}" wurde gelöscht.");
}
}
@@ -0,0 +1,67 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center space-x-2">
<a href="{{ route('admin.users.index') }}" class="text-gray-500 hover:text-gray-700">Benutzerverwaltung</a>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Neuer Benutzer</h2>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('admin.users.store') }}" class="space-y-6">
@csrf
{{-- Name --}}
<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
value="{{ old('name') }}" required autofocus />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
{{-- E-Mail --}}
<div>
<x-input-label for="email" value="E-Mail-Adresse" />
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full"
value="{{ old('email') }}" required />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
{{-- Passwort --}}
<div>
<x-input-label for="password" value="Passwort" />
<x-text-input id="password" name="password" type="password" class="mt-1 block w-full" required />
<p class="mt-1 text-xs text-gray-500">Mindestens 8 Zeichen, Groß- und Kleinbuchstaben, eine Zahl.</p>
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
{{-- Rolle --}}
<div>
<x-input-label for="role" value="Rolle" />
<select id="role" name="role" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<option value=""> Bitte wählen </option>
@foreach($roles as $role)
<option value="{{ $role->name }}" {{ old('role') === $role->name ? 'selected' : '' }}>
{{ ucfirst($role->name) }}
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('role')" class="mt-2" />
</div>
{{-- Buttons --}}
<div class="flex items-center justify-between pt-2">
<a href="{{ route('admin.users.index') }}"
class="text-sm text-gray-600 hover:text-gray-900">Abbrechen</a>
<x-primary-button>Benutzer anlegen</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,90 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center space-x-2">
<a href="{{ route('admin.users.index') }}" class="text-gray-500 hover:text-gray-700">Benutzerverwaltung</a>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ $user->name }} bearbeiten</h2>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- Stammdaten & Rolle --}}
<div class="bg-white shadow-sm sm:rounded-lg p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Stammdaten</h3>
<form method="POST" action="{{ route('admin.users.update', $user) }}" class="space-y-6">
@csrf
@method('PUT')
{{-- Name --}}
<div>
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
value="{{ old('name', $user->name) }}" required autofocus />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
{{-- E-Mail --}}
<div>
<x-input-label for="email" value="E-Mail-Adresse" />
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full"
value="{{ old('email', $user->email) }}" required />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
{{-- Neues Passwort --}}
<div>
<x-input-label for="password" value="Neues Passwort (leer lassen = unverändert)" />
<x-text-input id="password" name="password" type="password" class="mt-1 block w-full" />
<p class="mt-1 text-xs text-gray-500">Mindestens 8 Zeichen, Groß- und Kleinbuchstaben, eine Zahl.</p>
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
{{-- Rolle --}}
<div>
<x-input-label for="role" value="Rolle" />
<select id="role" name="role" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@foreach($roles as $role)
<option value="{{ $role->name }}"
{{ $user->hasRole($role->name) ? 'selected' : '' }}>
{{ ucfirst($role->name) }}
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('role')" class="mt-2" />
</div>
{{-- Buttons --}}
<div class="flex items-center justify-between pt-2">
<a href="{{ route('admin.users.index') }}"
class="text-sm text-gray-600 hover:text-gray-900">Abbrechen</a>
<x-primary-button>Änderungen speichern</x-primary-button>
</div>
</form>
</div>
{{-- Gefahrenzone --}}
@if($user->id !== auth()->id())
<div class="bg-white shadow-sm sm:rounded-lg p-6 border border-red-200">
<h3 class="text-lg font-medium text-red-700 mb-2">Gefahrenzone</h3>
<p class="text-sm text-gray-600 mb-4">
Dieser Benutzer wird dauerhaft gelöscht und kann nicht wiederhergestellt werden.
</p>
<form method="POST" action="{{ route('admin.users.destroy', $user) }}"
onsubmit="return confirm('Benutzer „{{ $user->name }}" wirklich löschen?')">
@csrf
@method('DELETE')
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 transition">
Benutzer löschen
</button>
</form>
</div>
@endif
</div>
</div>
</x-app-layout>
@@ -0,0 +1,93 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Benutzerverwaltung
</h2>
<a href="{{ route('admin.users.create') }}"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 transition">
+ Neuer Benutzer
</a>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
{{-- Flash-Meldungen --}}
@if(session('success'))
<div class="mb-4 p-4 bg-green-100 text-green-800 rounded-md">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="mb-4 p-4 bg-red-100 text-red-800 rounded-md">
{{ session('error') }}
</div>
@endif
<div class="bg-white shadow-sm sm:rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">E-Mail</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rolle</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Erstellt</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($users as $user)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="font-medium text-gray-900">{{ $user->name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{{ $user->email }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@foreach($user->roles as $role)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{{ $role->name === 'admin' ? 'bg-red-100 text-red-800' : ($role->name === 'manager' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800') }}">
{{ $role->name }}
</span>
@endforeach
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $user->created_at->format('d.m.Y') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<a href="{{ route('admin.users.edit', $user) }}"
class="text-indigo-600 hover:text-indigo-900">Bearbeiten</a>
@if($user->id !== auth()->id())
<form method="POST" action="{{ route('admin.users.destroy', $user) }}"
class="inline"
onsubmit="return confirm('Benutzer „{{ $user->name }}" wirklich löschen?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-900">Löschen</button>
</form>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
Noch keine Benutzer vorhanden.
</td>
</tr>
@endforelse
</tbody>
</table>
@if($users->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $users->links() }}
</div>
@endif
</div>
</div>
</div>
</x-app-layout>
@@ -15,6 +15,11 @@
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }} {{ __('Dashboard') }}
</x-nav-link> </x-nav-link>
@role('admin')
<x-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.*')">
Benutzerverwaltung
</x-nav-link>
@endrole
</div> </div>
</div> </div>
@@ -70,6 +75,11 @@
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }} {{ __('Dashboard') }}
</x-responsive-nav-link> </x-responsive-nav-link>
@role('admin')
<x-responsive-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.*')">
Benutzerverwaltung
</x-responsive-nav-link>
@endrole
</div> </div>
<!-- Responsive Settings Options --> <!-- Responsive Settings Options -->
+10
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\Admin\UserController as AdminUserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
@@ -17,4 +18,13 @@ Route::middleware('auth')->group(function () {
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
}); });
// Admin-Bereich nur für Admins
Route::prefix('admin')
->name('admin.')
->middleware(['auth', 'verified', 'role:admin'])
->group(function () {
Route::get('/', fn() => redirect()->route('admin.users.index'));
Route::resource('users', AdminUserController::class);
});
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';