Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill
- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er) - Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link - Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest - Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen) - Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
312
app/Http/Controllers/Admin/UserController.php
Executable file
312
app/Http/Controllers/Admin/UserController.php
Executable file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = User::with('children');
|
||||
|
||||
// Sortierung
|
||||
$sortable = ['name', 'email', 'role', 'last_login_at', 'is_active', 'created_at'];
|
||||
$sort = in_array($request->input('sort'), $sortable) ? $request->input('sort') : 'created_at';
|
||||
$direction = $request->input('direction') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
match ($sort) {
|
||||
'last_login_at' => $query->orderBy(DB::raw('CASE WHEN last_login_at IS NULL THEN 1 ELSE 0 END'))->orderBy('last_login_at', $direction),
|
||||
default => $query->orderBy($sort, $direction),
|
||||
};
|
||||
|
||||
$users = $query->paginate(20)->withQueryString();
|
||||
$trashedUsers = User::onlyTrashed()
|
||||
->where('deleted_at', '>=', now()->subDays(7))
|
||||
->latest('deleted_at')
|
||||
->get();
|
||||
|
||||
return view('admin.users.index', compact('users', 'trashedUsers', 'sort', 'direction'));
|
||||
}
|
||||
|
||||
public function edit(User $user): View
|
||||
{
|
||||
return view('admin.users.edit', compact('user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schutz: Coach darf keine Admin-Konten aendern (S01).
|
||||
*/
|
||||
private function guardAgainstCoachModifyingAdmin(User $user): void
|
||||
{
|
||||
if (!auth()->user()->isAdmin() && $user->isAdmin()) {
|
||||
abort(403, __('admin.cannot_modify_admin'));
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'phone' => ['nullable', 'string', 'max:30'],
|
||||
'role' => ['required', 'in:admin,coach,parent_rep,user'],
|
||||
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
|
||||
]);
|
||||
|
||||
// Selbstschutz: Eigene Rolle nicht ändern
|
||||
if ($user->id === auth()->id()) {
|
||||
unset($validated['role']);
|
||||
} elseif (isset($validated['role']) && $validated['role'] === 'admin' && !auth()->user()->isAdmin()) {
|
||||
abort(403, __('admin.cannot_assign_admin_role'));
|
||||
}
|
||||
|
||||
// Handle profile picture upload
|
||||
if ($request->hasFile('profile_picture')) {
|
||||
if ($user->profile_picture) {
|
||||
Storage::disk('public')->delete($user->profile_picture);
|
||||
}
|
||||
$file = $request->file('profile_picture');
|
||||
$storedName = 'avatars/' . Str::uuid() . '.' . $file->guessExtension();
|
||||
Storage::disk('public')->putFileAs('', $file, $storedName);
|
||||
$validated['profile_picture'] = $storedName;
|
||||
} else {
|
||||
unset($validated['profile_picture']);
|
||||
}
|
||||
|
||||
$oldData = ['name' => $user->name, 'email' => $user->email, 'role' => $user->role->value];
|
||||
|
||||
// Rolle separat setzen (nicht in $fillable fuer Mass-Assignment-Schutz)
|
||||
$newRole = $validated['role'] ?? null;
|
||||
unset($validated['role']);
|
||||
$user->update($validated);
|
||||
if ($newRole) {
|
||||
$user->role = $newRole;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
$newData = ['name' => $user->name, 'email' => $user->email, 'role' => $user->role->value];
|
||||
ActivityLog::logWithChanges('updated', __('admin.log_user_updated', ['name' => $user->name]), 'User', $user->id, $oldData, $newData);
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', __('admin.user_updated'));
|
||||
}
|
||||
|
||||
public function toggleActive(User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', __('admin.cannot_deactivate_self'));
|
||||
}
|
||||
|
||||
$oldActive = $user->is_active;
|
||||
|
||||
$user->is_active = !$user->is_active;
|
||||
$user->save();
|
||||
|
||||
$status = $user->is_active ? __('admin.activated') : __('admin.deactivated');
|
||||
ActivityLog::logWithChanges('toggled_active', __('admin.log_user_toggled', ['name' => $user->name, 'status' => $status]), 'User', $user->id, ['is_active' => $oldActive], ['is_active' => $user->is_active]);
|
||||
|
||||
return back()->with('success', __('admin.user_toggled', ['status' => $status]));
|
||||
}
|
||||
|
||||
public function updateRole(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', __('admin.cannot_change_own_role'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'role' => ['required', 'in:admin,coach,parent_rep,user'],
|
||||
]);
|
||||
|
||||
if ($validated['role'] === 'admin' && !auth()->user()->isAdmin()) {
|
||||
abort(403, __('admin.cannot_assign_admin_role'));
|
||||
}
|
||||
|
||||
$oldRole = $user->role->value;
|
||||
|
||||
$user->role = $validated['role'];
|
||||
$user->save();
|
||||
|
||||
ActivityLog::logWithChanges('role_changed', __('admin.log_role_changed', ['name' => $user->name, 'role' => $validated['role']]), 'User', $user->id, ['role' => $oldRole], ['role' => $validated['role']]);
|
||||
|
||||
return back()->with('success', __('admin.role_updated'));
|
||||
}
|
||||
|
||||
public function resetPassword(User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', __('admin.cannot_reset_own_password'));
|
||||
}
|
||||
|
||||
$status = Password::sendResetLink(['email' => $user->email]);
|
||||
|
||||
ActivityLog::log('password_reset', __('admin.log_password_reset', ['name' => $user->name]), 'User', $user->id);
|
||||
|
||||
if ($status === Password::RESET_LINK_SENT) {
|
||||
return redirect()->route('admin.users.edit', $user)
|
||||
->with('success', __('admin.password_reset_link_sent'));
|
||||
}
|
||||
|
||||
return redirect()->route('admin.users.edit', $user)
|
||||
->with('error', __($status));
|
||||
}
|
||||
|
||||
public function removePicture(User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
if ($user->profile_picture) {
|
||||
Storage::disk('public')->delete($user->profile_picture);
|
||||
$user->update(['profile_picture' => null]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('admin.picture_removed'));
|
||||
}
|
||||
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
// Schutz: nicht sich selbst und nicht User ID 1 löschen
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', __('admin.cannot_delete_self'));
|
||||
}
|
||||
if ($user->id === 1) {
|
||||
return back()->with('error', __('admin.cannot_delete_main_admin'));
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
ActivityLog::logWithChanges('deleted', __('admin.log_user_deleted', ['name' => $user->name]), 'User', $user->id, ['name' => $user->name, 'email' => $user->email, 'role' => $user->role->value], null);
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', __('admin.user_deleted'));
|
||||
}
|
||||
|
||||
public function restore(int $id): RedirectResponse
|
||||
{
|
||||
$user = User::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if (! $user->isRestorable()) {
|
||||
return back()->with('error', __('admin.restore_expired'));
|
||||
}
|
||||
|
||||
$user->restore();
|
||||
|
||||
ActivityLog::logWithChanges('restored', __('admin.log_user_restored', ['name' => $user->name]), 'User', $user->id, null, ['name' => $user->name, 'email' => $user->email]);
|
||||
|
||||
return back()->with('success', __('admin.user_restored'));
|
||||
}
|
||||
|
||||
public function toggleDsgvoConsent(User $user): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->hasDsgvoConsent()) {
|
||||
return back()->with('error', __('admin.dsgvo_no_file'));
|
||||
}
|
||||
|
||||
if ($user->isDsgvoConfirmed()) {
|
||||
$old = [
|
||||
'dsgvo_accepted_at' => $user->dsgvo_accepted_at->toDateTimeString(),
|
||||
'dsgvo_accepted_by' => $user->dsgvoAcceptedBy?->name ?? (string) $user->dsgvo_accepted_by,
|
||||
];
|
||||
$user->dsgvo_accepted_at = null;
|
||||
$user->dsgvo_accepted_by = null;
|
||||
$user->save();
|
||||
ActivityLog::logWithChanges(
|
||||
'dsgvo_consent_revoked',
|
||||
__('admin.log_dsgvo_revoked', ['name' => $user->name]),
|
||||
'User', $user->id, $old, null
|
||||
);
|
||||
} else {
|
||||
$user->dsgvo_accepted_at = now();
|
||||
$user->dsgvo_accepted_by = auth()->id();
|
||||
$user->save();
|
||||
ActivityLog::logWithChanges(
|
||||
'dsgvo_consent_confirmed',
|
||||
__('admin.log_dsgvo_confirmed', ['name' => $user->name]),
|
||||
'User', $user->id, null,
|
||||
[
|
||||
'dsgvo_accepted_at' => now()->toDateTimeString(),
|
||||
'dsgvo_accepted_by' => auth()->user()->name,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return back()->with('success', __('admin.dsgvo_toggled'));
|
||||
}
|
||||
|
||||
public function rejectDsgvoConsent(User $user): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->hasDsgvoConsent()) {
|
||||
return back()->with('error', __('admin.dsgvo_no_file'));
|
||||
}
|
||||
|
||||
// Path-Traversal-Schutz
|
||||
if ($user->dsgvo_consent_file && !str_starts_with($user->dsgvo_consent_file, 'dsgvo/')) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Datei vom Disk löschen
|
||||
Storage::disk('local')->delete($user->dsgvo_consent_file);
|
||||
|
||||
$user->dsgvo_consent_file = null;
|
||||
$user->dsgvo_accepted_at = null;
|
||||
$user->dsgvo_accepted_by = null;
|
||||
$user->save();
|
||||
|
||||
ActivityLog::log(
|
||||
'dsgvo_consent_rejected',
|
||||
__('admin.log_dsgvo_rejected', ['name' => $user->name]),
|
||||
'User',
|
||||
$user->id
|
||||
);
|
||||
|
||||
return back()->with('success', __('admin.dsgvo_rejected'));
|
||||
}
|
||||
|
||||
public function viewDsgvoConsent(User $user)
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->dsgvo_consent_file || !str_starts_with($user->dsgvo_consent_file, 'dsgvo/') || !Storage::disk('local')->exists($user->dsgvo_consent_file)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
ActivityLog::log('dsgvo_document_viewed', __('admin.log_dsgvo_viewed', ['name' => $user->name]), 'User', $user->id);
|
||||
|
||||
$mimeType = Storage::disk('local')->mimeType($user->dsgvo_consent_file);
|
||||
|
||||
return response()->file(
|
||||
Storage::disk('local')->path($user->dsgvo_consent_file),
|
||||
['Content-Type' => $mimeType]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user