- 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>
313 lines
11 KiB
PHP
Executable File
313 lines
11 KiB
PHP
Executable File
<?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]
|
|
);
|
|
}
|
|
}
|