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:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

View File

@@ -0,0 +1,264 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Comment;
use App\Models\Event;
use App\Models\EventCatering;
use App\Models\EventParticipant;
use App\Models\EventTimekeeper;
use App\Models\Player;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class ActivityLogController extends Controller
{
public function index(Request $request): View
{
if (!auth()->user()->canViewActivityLog()) {
abort(403);
}
$request->validate([
'category' => ['nullable', 'string', 'in:auth,users,players,events,files,settings,dsgvo'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
]);
$query = ActivityLog::with('user')->latest('created_at');
// Filter: Kategorie
if ($request->filled('category')) {
$actionMap = [
'auth' => ['login', 'logout', 'login_failed', 'registered'],
'users' => ['updated', 'toggled_active', 'role_changed', 'password_reset', 'deleted', 'restored'],
'players' => ['created', 'updated', 'deleted', 'restored', 'parent_assigned', 'parent_removed'],
'events' => ['created', 'updated', 'deleted', 'participant_status_changed'],
'files' => ['uploaded', 'deleted'],
'settings' => ['updated'],
'dsgvo' => ['dsgvo_consent_uploaded', 'dsgvo_consent_confirmed', 'dsgvo_consent_revoked', 'dsgvo_consent_removed', 'dsgvo_consent_rejected', 'account_self_deleted', 'child_auto_deactivated'],
];
$category = $request->input('category');
if (isset($actionMap[$category])) {
if ($category === 'auth' || $category === 'dsgvo') {
$query->whereIn('action', $actionMap[$category]);
} else {
$modelTypeMap = [
'users' => 'User',
'players' => 'Player',
'events' => 'Event',
'files' => 'File',
'settings' => 'Setting',
];
$query->where('model_type', $modelTypeMap[$category] ?? null);
}
}
}
// Filter: Datum von
if ($request->filled('from')) {
$query->whereDate('created_at', '>=', $request->input('from'));
}
// Filter: Datum bis
if ($request->filled('to')) {
$query->whereDate('created_at', '<=', $request->input('to'));
}
$logs = $query->paginate(30)->withQueryString();
return view('admin.activity-logs.index', compact('logs'));
}
public function revert(ActivityLog $log): RedirectResponse
{
if (!auth()->user()->canViewActivityLog()) {
abort(403);
}
$old = $log->properties['old'] ?? [];
$revertableActions = ['deleted', 'toggled_active', 'role_changed', 'status_changed', 'participant_status_changed'];
if (!in_array($log->action, $revertableActions) || !$log->model_id) {
return back()->with('error', __('admin.log_revert_not_possible'));
}
$success = match ($log->action) {
'deleted' => $this->revertDelete($log),
'toggled_active' => $this->revertToggleActive($log, $old),
'role_changed' => $this->revertRoleChange($log, $old),
'status_changed' => $this->revertStatusChange($log, $old),
'participant_status_changed' => $this->revertParticipantStatus($log, $old),
default => false,
};
if (!$success) {
return back()->with('error', __('admin.log_revert_not_possible'));
}
ActivityLog::log('reverted', __('admin.log_reverted', ['desc' => Str::limit($log->description, 80)]), $log->model_type, $log->model_id);
return back()->with('success', __('admin.log_revert_success'));
}
private function revertDelete(ActivityLog $log): bool
{
return match ($log->model_type) {
'User' => $this->restoreModel(User::class, $log->model_id),
'Player' => $this->restoreModel(Player::class, $log->model_id),
'Event' => $this->restoreEvent($log->model_id),
'Comment' => $this->restoreComment($log->model_id),
default => false,
};
}
private function restoreModel(string $class, int $id): bool
{
$model = $class::onlyTrashed()->find($id);
if (!$model) {
return false;
}
$model->restore();
return true;
}
private function restoreEvent(int $id): bool
{
$event = Event::onlyTrashed()->find($id);
if (!$event) {
return false;
}
$event->deleted_by = null;
$event->save();
$event->restore();
return true;
}
private function restoreComment(int $id): bool
{
$comment = Comment::find($id);
if (!$comment || !$comment->isDeleted()) {
return false;
}
$comment->deleted_at = null;
$comment->deleted_by = null;
$comment->save();
return true;
}
private function revertToggleActive(ActivityLog $log, array $old): bool
{
$model = match ($log->model_type) {
'User' => User::withTrashed()->find($log->model_id),
'Player' => Player::withTrashed()->find($log->model_id),
default => null,
};
if (!$model || !isset($old['is_active'])) {
return false;
}
$model->is_active = filter_var($old['is_active'], FILTER_VALIDATE_BOOLEAN);
$model->save();
return true;
}
private function revertRoleChange(ActivityLog $log, array $old): bool
{
if ($log->model_type !== 'User' || !isset($old['role'])) {
return false;
}
// Enum-Validierung: Nur gültige Rollen-Werte akzeptieren (V02)
$validRoles = array_column(UserRole::cases(), 'value');
if (!in_array($old['role'], $validRoles, true)) {
return false;
}
$user = User::withTrashed()->find($log->model_id);
if (!$user) {
return false;
}
// Selbst-Rollen-Änderung verhindern
if ($user->id === auth()->id()) {
return false;
}
// Nicht-Admins dürfen keine Admin-Rolle zuweisen oder Admin-Rollen rückgängig machen
if (!auth()->user()->isAdmin() && ($user->isAdmin() || $old['role'] === 'admin')) {
return false;
}
$user->role = $old['role'];
$user->save();
return true;
}
private function revertStatusChange(ActivityLog $log, array $old): bool
{
if (!isset($old['status']) || $log->model_type !== 'Event') {
return false;
}
$userId = $log->properties['old']['user_id'] ?? $log->properties['new']['user_id'] ?? null;
if (!$userId) {
return false;
}
$catering = EventCatering::where('event_id', $log->model_id)->where('user_id', $userId)->first();
if ($catering) {
$catering->update(['status' => $old['status']]);
return true;
}
$timekeeper = EventTimekeeper::where('event_id', $log->model_id)->where('user_id', $userId)->first();
if ($timekeeper) {
$timekeeper->update(['status' => $old['status']]);
return true;
}
return false;
}
private function revertParticipantStatus(ActivityLog $log, array $old): bool
{
if (!isset($old['status']) || $log->model_type !== 'Event') {
return false;
}
// Spieler-ID bevorzugen, Namens-Suche als Fallback (DB-agnostisch)
$participant = EventParticipant::where('event_id', $log->model_id)
->when(
isset($old['participant_id']),
fn ($q) => $q->where('id', $old['participant_id']),
fn ($q) => $q->when(
isset($old['player']),
fn ($q2) => $q2->whereHas('player', function ($pq) use ($old) {
$parts = explode(' ', $old['player'], 2);
if (count($parts) === 2) {
$pq->where('first_name', $parts[0])->where('last_name', $parts[1]);
}
})
)
)
->first();
if (!$participant) {
return false;
}
$participant->status = $old['status'];
$participant->set_by_user_id = auth()->id();
$participant->responded_at = now();
$participant->save();
return true;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Comment;
use Illuminate\Http\RedirectResponse;
class CommentController extends Controller
{
public function softDelete(Comment $comment): RedirectResponse
{
$comment->deleted_at = now();
$comment->deleted_by = auth()->id();
$comment->save();
ActivityLog::logWithChanges('deleted', __('admin.log_comment_deleted', ['event' => $comment->event?->title]), 'Event', $comment->event_id, ['comment' => mb_substr($comment->body, 0, 100), 'event' => $comment->event?->title], null);
return back()->with('success', __('events.comment_removed'));
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Enums\EventStatus;
use App\Enums\ParticipantStatus;
use App\Enums\UserRole;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\Invitation;
use App\Models\Player;
use App\Models\User;
use App\Services\SupportApiService;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(): View
{
$stats = [
'users' => User::count(),
'players' => Player::count(),
'upcoming_events' => Event::where('status', EventStatus::Published)
->where('start_at', '>=', now())->count(),
'open_invitations' => Invitation::whereNull('accepted_at')
->where('expires_at', '>', now())->count(),
];
// Events mit vielen offenen Rückmeldungen
$eventsWithOpenResponses = Event::with('team')
->where('status', EventStatus::Published)
->where('start_at', '>=', now())
->withCount(['participants as open_count' => function ($q) {
$q->where('status', ParticipantStatus::Unknown);
}])
->orderByDesc('open_count')
->limit(10)
->get()
->filter(fn ($e) => $e->open_count > 0)
->take(5);
// DSGVO: User mit hochgeladenem Dokument, aber ohne Admin-Bestätigung
$pendingDsgvoUsers = User::where('role', UserRole::User)
->whereNotNull('dsgvo_consent_file')
->whereNull('dsgvo_accepted_at')
->orderBy('name')
->get();
// Letzte 10 DSGVO-Ereignisse
$dsgvoEvents = ActivityLog::with('user')
->whereIn('action', [
'dsgvo_consent_uploaded',
'dsgvo_consent_confirmed',
'dsgvo_consent_revoked',
'dsgvo_consent_removed',
'dsgvo_consent_rejected',
'account_self_deleted',
'child_auto_deactivated',
])
->latest('created_at')
->limit(10)
->get();
// Update-Check (cached 24h, nur wenn registriert)
$supportService = app(SupportApiService::class);
if ($supportService->isRegistered()) {
$supportService->checkForUpdate();
}
$hasUpdate = $supportService->hasUpdate();
$updateVersion = $hasUpdate
? (Cache::get('support.update_check')['latest_version'] ?? null)
: null;
return view('admin.dashboard', compact(
'stats', 'eventsWithOpenResponses', 'pendingDsgvoUsers', 'dsgvoEvents',
'hasUpdate', 'updateVersion'
));
}
}

View File

@@ -0,0 +1,432 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\EventCatering;
use App\Models\EventTimekeeper;
use App\Models\File;
use App\Models\FileCategory;
use App\Models\Location;
use App\Models\Setting;
use App\Models\Team;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Services\HtmlSanitizerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EventController extends Controller
{
public function __construct(private HtmlSanitizerService $sanitizer) {}
public function index(Request $request): View
{
$query = Event::with(['team', 'participants'])
->withCount([
'caterings as caterings_yes_count' => fn ($q) => $q->where('status', 'yes'),
'timekeepers as timekeepers_yes_count' => fn ($q) => $q->where('status', 'yes'),
])
->latest('start_at');
// Team-Scoping: Coach/ParentRep sehen nur eigene Teams (V04)
$user = auth()->user();
if (!$user->isAdmin()) {
$teamIds = $user->isCoach()
? $user->coachTeams()->pluck('teams.id')
: $user->accessibleTeamIds();
$query->whereIn('team_id', $teamIds);
}
if ($request->filled('team_id')) {
$query->forTeam($request->team_id);
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$events = $query->paginate(20)->withQueryString();
$teams = Team::active()->orderBy('name')->get();
$trashedEvents = Event::onlyTrashed()->with('team')->latest('deleted_at')->get();
return view('admin.events.index', compact('events', 'teams', 'trashedEvents'));
}
public function create(): View
{
$teams = Team::active()->orderBy('name')->get();
$types = EventType::cases();
$statuses = EventStatus::cases();
$teamParents = $this->getTeamParents();
$eventDefaults = $this->getEventDefaults();
$knownLocations = Location::orderBy('name')->get();
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->get();
return view('admin.events.create', compact('teams', 'types', 'statuses', 'teamParents', 'eventDefaults', 'knownLocations', 'fileCategories'));
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validateEvent($request);
$validated['description_html'] = $this->sanitizer->sanitize($validated['description_html'] ?? '');
$this->normalizeMinFields($validated);
$event = Event::create($validated);
$event->created_by = $request->user()->id;
$event->updated_by = $request->user()->id;
$event->save();
$this->createParticipantsForTeam($event);
$this->syncAssignments($event, $request);
$this->saveKnownLocation($validated, $request->input('location_name'));
$this->syncEventFiles($event, $request);
ActivityLog::logWithChanges('created', __('admin.log_event_created', ['title' => $event->title]), 'Event', $event->id, null, ['title' => $event->title, 'team' => $event->team->name ?? '', 'type' => $event->type->value, 'status' => $event->status->value]);
return redirect()->route('admin.events.index')
->with('success', __('admin.event_created'));
}
public function edit(Event $event): View
{
// Team-Scoping: Nicht-Admins dürfen nur Events ihrer Teams sehen (V04)
$user = auth()->user();
if (!$user->isAdmin()) {
$teamIds = $user->isCoach()
? $user->coachTeams()->pluck('teams.id')->toArray()
: $user->accessibleTeamIds()->toArray();
if (!in_array($event->team_id, $teamIds)) {
abort(403);
}
}
$teams = Team::active()->orderBy('name')->get();
$types = EventType::cases();
$statuses = EventStatus::cases();
$teamParents = $this->getTeamParents();
$eventDefaults = $this->getEventDefaults();
$event->syncParticipants(auth()->id());
$participantRelations = $event->type === EventType::Meeting
? ['participants.user']
: ['participants.player'];
$event->load(array_merge($participantRelations, ['caterings', 'timekeepers', 'files.category']));
$assignedCatering = $event->caterings->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
$assignedTimekeeper = $event->timekeepers->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
$knownLocations = Location::orderBy('name')->get();
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->get();
return view('admin.events.edit', compact('event', 'teams', 'types', 'statuses', 'teamParents', 'assignedCatering', 'assignedTimekeeper', 'eventDefaults', 'knownLocations', 'fileCategories'));
}
public function update(Request $request, Event $event): RedirectResponse
{
$validated = $this->validateEvent($request);
$validated['description_html'] = $this->sanitizer->sanitize($validated['description_html'] ?? '');
$this->normalizeMinFields($validated);
$oldData = ['title' => $event->title, 'team_id' => $event->team_id, 'type' => $event->type->value, 'status' => $event->status->value, 'start_at' => $event->start_at?->toDateTimeString()];
$oldTeamId = $event->team_id;
$event->update($validated);
$event->updated_by = $request->user()->id;
$event->save();
if ($oldTeamId !== (int) $validated['team_id']) {
$event->participants()->delete();
$this->createParticipantsForTeam($event);
} else {
$event->syncParticipants($request->user()->id);
}
$this->syncAssignments($event, $request);
$this->saveKnownLocation($validated, $request->input('location_name'));
$this->syncEventFiles($event, $request);
$newData = ['title' => $event->title, 'team_id' => $event->team_id, 'type' => $event->type->value, 'status' => $event->status->value, 'start_at' => $event->start_at?->toDateTimeString()];
ActivityLog::logWithChanges('updated', __('admin.log_event_updated', ['title' => $event->title]), 'Event', $event->id, $oldData, $newData);
return redirect()->route('admin.events.index')
->with('success', __('admin.event_updated'));
}
public function updateParticipant(Request $request, Event $event)
{
$validated = $request->validate([
'participant_id' => ['required', 'integer'],
'status' => ['required', 'in:yes,no,unknown'],
]);
$participant = $event->participants()->where('id', $validated['participant_id'])->firstOrFail();
$oldStatus = $participant->status->value;
$participant->status = $validated['status'];
$participant->set_by_user_id = $request->user()->id;
$participant->responded_at = now();
$participant->save();
$participantLabel = $participant->user_id
? ($participant->user?->name ?? '')
: ($participant->player?->full_name ?? '');
ActivityLog::logWithChanges('participant_status_changed', __('admin.log_participant_changed', ['event' => $event->title, 'status' => $validated['status']]), 'Event', $event->id, ['status' => $oldStatus, 'player' => $participantLabel], ['status' => $validated['status']]);
return response()->json(['success' => true]);
}
public function destroy(Event $event): RedirectResponse
{
ActivityLog::logWithChanges('deleted', __('admin.log_event_deleted', ['title' => $event->title]), 'Event', $event->id, ['title' => $event->title, 'team' => $event->team->name ?? ''], null);
$event->deleted_by = auth()->id();
$event->save();
$event->delete();
return redirect()->route('admin.events.index')
->with('success', __('admin.event_deleted'));
}
public function restore(int $id): RedirectResponse
{
$event = Event::onlyTrashed()->findOrFail($id);
$event->deleted_by = null;
$event->save();
$event->restore();
ActivityLog::logWithChanges('restored', __('admin.log_event_restored', ['title' => $event->title]), 'Event', $event->id, null, ['title' => $event->title, 'team' => $event->team->name ?? '']);
return redirect()->route('admin.events.index')
->with('success', __('admin.event_restored'));
}
private function validateEvent(Request $request): array
{
$request->validate([
'catering_users' => ['nullable', 'array'],
'catering_users.*' => ['integer', 'exists:users,id'],
'timekeeper_users' => ['nullable', 'array'],
'timekeeper_users.*' => ['integer', 'exists:users,id'],
'existing_files' => ['nullable', 'array'],
'existing_files.*' => ['integer', 'exists:files,id'],
'new_files' => ['nullable', 'array'],
'new_files.*' => ['file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
'new_file_categories' => ['nullable', 'array'],
'new_file_categories.*' => ['integer', 'exists:file_categories,id'],
]);
$validated = $request->validate([
'team_id' => ['required', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
$team = Team::find($value);
if (!$team || !$team->is_active) {
$fail(__('validation.exists', ['attribute' => $attribute]));
}
}],
'type' => ['required', 'in:' . implode(',', array_column(EventType::cases(), 'value'))],
'title' => ['required', 'string', 'max:255'],
'start_date' => ['required', 'date'],
'start_time' => ['required', 'date_format:H:i'],
'status' => ['required', 'in:' . implode(',', array_column(EventStatus::cases(), 'value'))],
'location_name' => ['nullable', 'string', 'max:255'],
'address_text' => ['nullable', 'string', 'max:500'],
'location_lat' => ['nullable', 'numeric', 'between:-90,90'],
'location_lng' => ['nullable', 'numeric', 'between:-180,180'],
'description_html' => ['nullable', 'string', 'max:50000'],
'min_players' => ['nullable', 'integer', 'min:0', 'max:30'],
'min_catering' => ['nullable', 'integer', 'min:0', 'max:8'],
'min_timekeepers' => ['nullable', 'integer', 'min:0', 'max:8'],
'opponent' => ['nullable', 'string', 'max:100'],
'score_home' => ['nullable', 'integer', 'min:0', 'max:99'],
'score_away' => ['nullable', 'integer', 'min:0', 'max:99'],
]);
// Datum und Uhrzeit zusammenführen
$validated['start_at'] = $validated['start_date'] . ' ' . $validated['start_time'];
$validated['end_at'] = null;
unset($validated['start_date'], $validated['start_time']);
return $validated;
}
private function createParticipantsForTeam(Event $event): void
{
if ($event->type === EventType::Meeting) {
$event->syncMeetingParticipants(auth()->id());
return;
}
$activePlayers = $event->team->activePlayers;
$userId = auth()->id();
$records = $activePlayers->map(fn ($player) => [
'event_id' => $event->id,
'player_id' => $player->id,
'status' => ParticipantStatus::Unknown->value,
'set_by_user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
])->toArray();
if (!empty($records)) {
$event->participants()->insert($records);
}
}
private function syncAssignments(Event $event, Request $request): void
{
// Auswärtsspiele und Besprechungen haben kein Catering/Zeitnehmer
if (!$event->type->hasCatering() && !$event->type->hasTimekeepers()) {
return;
}
$cateringUsers = $request->input('catering_users', []);
$timekeeperUsers = $request->input('timekeeper_users', []);
// Catering: set assigned users to Yes, remove unassigned admin-set entries
$event->caterings()->whereNotIn('user_id', $cateringUsers)->where('status', CateringStatus::Yes)->delete();
foreach ($cateringUsers as $userId) {
$catering = EventCatering::where('event_id', $event->id)->where('user_id', $userId)->first();
if (!$catering) {
$catering = new EventCatering(['event_id' => $event->id]);
$catering->user_id = $userId;
}
$catering->status = CateringStatus::Yes;
$catering->save();
}
// Timekeeper: same pattern
$event->timekeepers()->whereNotIn('user_id', $timekeeperUsers)->where('status', CateringStatus::Yes)->delete();
foreach ($timekeeperUsers as $userId) {
$timekeeper = EventTimekeeper::where('event_id', $event->id)->where('user_id', $userId)->first();
if (!$timekeeper) {
$timekeeper = new EventTimekeeper(['event_id' => $event->id]);
$timekeeper->user_id = $userId;
}
$timekeeper->status = CateringStatus::Yes;
$timekeeper->save();
}
}
private function normalizeMinFields(array &$validated): void
{
foreach (['min_players', 'min_catering', 'min_timekeepers'] as $field) {
$validated[$field] = isset($validated[$field]) && $validated[$field] !== '' ? (int) $validated[$field] : null;
}
// Auswärtsspiele und Besprechungen: kein Catering/Zeitnehmer
$type = EventType::tryFrom($validated['type'] ?? '');
if ($type && !$type->hasCatering()) {
$validated['min_catering'] = null;
}
if ($type && !$type->hasTimekeepers()) {
$validated['min_timekeepers'] = null;
}
// Nicht-Spiel-Typen: kein Gegner/Ergebnis
if ($type && !$type->isGameType()) {
$validated['opponent'] = null;
$validated['score_home'] = null;
$validated['score_away'] = null;
}
}
private function getEventDefaults(): array
{
$defaults = [];
foreach (['home_game', 'away_game', 'training', 'tournament', 'meeting', 'other'] as $type) {
$defaults[$type] = [
'min_players' => Setting::get("default_min_players_{$type}"),
'min_catering' => Setting::get("default_min_catering_{$type}"),
'min_timekeepers' => Setting::get("default_min_timekeepers_{$type}"),
];
}
return $defaults;
}
private function getTeamParents(): array
{
return Team::active()->with(['players' => fn ($q) => $q->active(), 'players.parents' => fn ($q) => $q->active()])
->get()
->mapWithKeys(fn ($team) => [
$team->id => $team->players->flatMap(fn ($p) => $p->parents)->unique('id')
->map(fn ($u) => ['id' => $u->id, 'name' => $u->name])
->values()
->toArray(),
])
->toArray();
}
private function syncEventFiles(Event $event, Request $request): void
{
// Attach existing files from library
$existingFileIds = $request->input('existing_files', []);
// Upload new files
$newFileIds = [];
$newFiles = $request->file('new_files', []);
$newCategories = $request->input('new_file_categories', []);
foreach ($newFiles as $index => $uploadedFile) {
if (!$uploadedFile || !$uploadedFile->isValid()) {
continue;
}
$categoryId = $newCategories[$index] ?? null;
if (!$categoryId) {
continue;
}
$extension = $uploadedFile->guessExtension();
$storedName = Str::uuid() . '.' . $extension;
Storage::disk('local')->putFileAs('files', $uploadedFile, $storedName);
$file = new File([
'file_category_id' => $categoryId,
'original_name' => $uploadedFile->getClientOriginalName(),
'mime_type' => $uploadedFile->getClientMimeType(),
'size' => $uploadedFile->getSize(),
]);
$file->stored_name = $storedName;
$file->disk = 'private';
$file->uploaded_by = auth()->id();
$file->save();
$newFileIds[] = $file->id;
}
// Merge existing + new file IDs and sync
$allFileIds = array_merge(
array_map('intval', $existingFileIds),
$newFileIds
);
$event->files()->sync($allFileIds);
}
private function saveKnownLocation(array $validated, ?string $locationName): void
{
if (empty($locationName) || empty($validated['address_text'])) {
return;
}
Location::updateOrCreate(
['name' => $locationName],
[
'address_text' => $validated['address_text'],
'location_lat' => $validated['location_lat'] ?? null,
'location_lng' => $validated['location_lng'] ?? null,
]
);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\FileCategory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class FileCategoryController extends Controller
{
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
]);
$slug = Str::slug($request->name);
// Ensure unique slug
$originalSlug = $slug;
$counter = 1;
while (FileCategory::where('slug', $slug)->exists()) {
$slug = $originalSlug . '-' . $counter++;
}
$maxOrder = FileCategory::max('sort_order') ?? 0;
FileCategory::create([
'name' => $request->name,
'slug' => $slug,
'sort_order' => $maxOrder + 1,
]);
return back()->with('success', __('admin.category_created'));
}
public function update(Request $request, FileCategory $category): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'is_active' => ['nullable', 'boolean'],
]);
$category->update([
'name' => $request->name,
'is_active' => $request->boolean('is_active'),
]);
return back()->with('success', __('admin.category_updated'));
}
public function destroy(FileCategory $category): RedirectResponse
{
if ($category->files()->exists()) {
return back()->with('error', __('admin.category_not_empty'));
}
$category->delete();
return back()->with('success', __('admin.category_deleted'));
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\File;
use App\Models\FileCategory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class FileController extends Controller
{
public function index(Request $request): View
{
$categories = FileCategory::ordered()->withCount('files')->get();
$activeCategory = $request->query('category');
$query = File::with(['category', 'uploader'])->latest();
if ($activeCategory) {
$query->whereHas('category', fn ($q) => $q->where('slug', $activeCategory));
}
$files = $query->paginate(25)->withQueryString();
return view('admin.files.index', compact('categories', 'files', 'activeCategory'));
}
public function create(): View
{
$categories = FileCategory::active()->ordered()->get();
return view('admin.files.create', compact('categories'));
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'file' => ['required', 'file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
'file_category_id' => ['required', 'exists:file_categories,id'],
]);
$uploadedFile = $request->file('file');
$extension = $uploadedFile->guessExtension();
$storedName = Str::uuid() . '.' . $extension;
Storage::disk('local')->putFileAs('files', $uploadedFile, $storedName);
$file = new File([
'file_category_id' => $request->file_category_id,
'original_name' => $uploadedFile->getClientOriginalName(),
'mime_type' => $uploadedFile->getClientMimeType(),
'size' => $uploadedFile->getSize(),
]);
$file->stored_name = $storedName;
$file->disk = 'private';
$file->uploaded_by = auth()->id();
$file->save();
ActivityLog::logWithChanges('uploaded', __('admin.log_file_uploaded', ['name' => $file->original_name]), 'File', $file->id, null, ['name' => $file->original_name, 'category' => $file->category->name ?? '']);
return redirect()->route('admin.files.index')
->with('success', __('admin.file_uploaded'));
}
public function destroy(File $file): RedirectResponse
{
// Path-Traversal-Schutz (V15)
if (str_contains($file->stored_name, '..') || str_contains($file->stored_name, '/')) {
abort(403);
}
ActivityLog::logWithChanges('deleted', __('admin.log_file_deleted', ['name' => $file->original_name]), 'File', $file->id, ['name' => $file->original_name, 'category' => $file->category->name ?? ''], null);
Storage::disk('local')->delete('files/' . $file->stored_name);
$file->delete();
return back()->with('success', __('admin.file_deleted'));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\GeocodingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GeocodingController extends Controller
{
public function __construct(private GeocodingService $geocoding) {}
public function search(Request $request): JsonResponse
{
$request->validate([
'q' => ['required', 'string', 'min:3', 'max:255'],
]);
$results = $this->geocoding->search($request->q);
return response()->json($results);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Invitation;
use App\Models\Player;
use App\Services\InvitationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class InvitationController extends Controller
{
public function __construct(private InvitationService $invitationService) {}
public function index(): View
{
$invitations = Invitation::with(['creator', 'players.team'])
->latest('created_at')
->paginate(20);
return view('admin.invitations.index', compact('invitations'));
}
public function create(): View
{
$players = Player::with('team')->active()->orderBy('last_name')->get();
return view('admin.invitations.create', compact('players'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'email' => ['nullable', 'email', 'max:255'],
'expires_in_days' => ['required', 'integer', 'min:1', 'max:90'],
'player_ids' => ['nullable', 'array'],
'player_ids.*' => ['exists:players,id'],
]);
$invitation = $this->invitationService->createInvitation($validated, $request->user());
$link = route('register', $invitation->raw_token);
ActivityLog::logWithChanges('created', __('admin.log_invitation_created', ['email' => $validated['email'] ?? '']), 'User', null, null, ['email' => $validated['email'] ?? '']);
return redirect()->route('admin.invitations.index')
->with('success', __('admin.invitation_created', ['link' => $link]));
}
public function destroy(Invitation $invitation): RedirectResponse
{
if ($invitation->isAccepted()) {
return back()->with('error', __('admin.invitation_already_used'));
}
$invitation->delete();
return back()->with('success', __('admin.invitation_deleted'));
}
}

View File

@@ -0,0 +1,255 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\File;
use App\Models\FileCategory;
use App\Models\Player;
use App\Models\Team;
use App\Models\User;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class ListGeneratorController extends Controller
{
public function create(): View
{
$teams = Team::where('is_active', true)->orderBy('name')->get();
return view('admin.list-generator.create', compact('teams'));
}
public function store(Request $request): View
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'subtitle' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:2000',
'team_id' => 'nullable|exists:teams,id',
'source' => 'required|in:players,parents,freetext',
'freetext_rows' => 'nullable|required_if:source,freetext|string|max:50000',
'columns' => 'nullable|array',
'custom_columns' => 'nullable|array',
'custom_columns.*' => 'string|max:100',
]);
$columns = $this->buildColumns($validated);
$rows = $this->buildRows($validated, $columns);
// Auto-detect orientation and font size for single-page PDF
$colCount = count($columns);
$rowCount = count($rows);
$orientation = $colCount > 4 ? 'landscape' : 'portrait';
// Font size calculation for single-page fit
$fontSize = 10;
if ($rowCount > 35) {
$fontSize = 7;
} elseif ($rowCount > 25) {
$fontSize = 8;
} elseif ($rowCount > 15) {
$fontSize = 9;
}
$viewData = [
'title' => $validated['title'],
'subtitle' => $validated['subtitle'] ?? null,
'notes' => $validated['notes'] ?? null,
'columns' => $columns,
'rows' => $rows,
'generatedAt' => now(),
'orientation' => $orientation,
'fontSize' => $fontSize,
];
// Generate PDF
$pdf = Pdf::loadView('admin.list-generator.document', $viewData)
->setPaper('a4', $orientation);
$pdfContent = $pdf->output();
// Save to file library
$category = FileCategory::where('slug', 'allgemein')->firstOrFail();
$storedName = Str::uuid() . '.pdf';
Storage::disk('local')->put('files/' . $storedName, $pdfContent);
$file = new File([
'file_category_id' => $category->id,
'original_name' => Str::slug($validated['title']) . '.pdf',
'mime_type' => 'application/pdf',
'size' => strlen($pdfContent),
]);
$file->stored_name = $storedName;
$file->disk = 'private';
$file->uploaded_by = auth()->id();
$file->save();
ActivityLog::log('created', __('admin.log_list_generated', ['title' => $validated['title']]), 'File', $file->id);
return view('admin.list-generator.result', [
'title' => $validated['title'],
'subtitle' => $validated['subtitle'] ?? null,
'notes' => $validated['notes'] ?? null,
'columns' => $columns,
'rows' => $rows,
'file' => $file,
]);
}
private function buildColumns(array $data): array
{
$columns = ['name' => __('ui.name')];
$selected = $data['columns'] ?? [];
$playerColumns = [
'team' => __('admin.nav_teams'),
'jersey_number' => __('admin.jersey_number'),
'birth_year' => __('admin.birth_year'),
'parents' => __('admin.parents'),
'photo_permission' => __('admin.photo_permission'),
];
$parentColumns = [
'team' => __('admin.nav_teams'),
'email' => __('ui.email'),
'phone' => __('admin.phone'),
'children' => __('admin.children'),
];
$available = match ($data['source']) {
'players' => $playerColumns,
'parents' => $parentColumns,
default => [],
};
foreach ($selected as $col) {
if (isset($available[$col])) {
$columns[$col] = $available[$col];
}
}
foreach (($data['custom_columns'] ?? []) as $i => $header) {
$header = trim($header);
if ($header !== '') {
$columns['custom_' . $i] = $header;
}
}
return $columns;
}
private function buildRows(array $data, array $columns): array
{
if ($data['source'] === 'freetext') {
return $this->buildFreetextRows($data);
}
if ($data['source'] === 'players') {
return $this->buildPlayerRows($data, $columns);
}
return $this->buildParentRows($data, $columns);
}
private function buildPlayerRows(array $data, array $columns): array
{
$query = Player::with(['team', 'parents'])->where('is_active', true);
if (!empty($data['team_id'])) {
$query->where('team_id', $data['team_id']);
}
$query->orderBy('last_name')->orderBy('first_name');
$players = $query->get();
$rows = [];
foreach ($players as $player) {
$row = ['name' => $player->full_name];
if (isset($columns['team'])) {
$row['team'] = $player->team->name ?? '';
}
if (isset($columns['jersey_number'])) {
$row['jersey_number'] = $player->jersey_number ?? '';
}
if (isset($columns['birth_year'])) {
$row['birth_year'] = $player->birth_year ?? '';
}
if (isset($columns['parents'])) {
$row['parents'] = $player->parents->map(fn ($p) => $p->name)->implode(', ') ?: '';
}
if (isset($columns['photo_permission'])) {
$row['photo_permission'] = $player->photo_permission ? __('ui.yes') : __('ui.no');
}
foreach ($columns as $key => $header) {
if (str_starts_with($key, 'custom_')) {
$row[$key] = '';
}
}
$rows[] = $row;
}
return $rows;
}
private function buildParentRows(array $data, array $columns): array
{
$query = User::with('children.team')->where('is_active', true);
if (!empty($data['team_id'])) {
$query->whereHas('children', fn ($q) => $q->where('team_id', $data['team_id']));
}
$query->orderBy('name');
$users = $query->get();
$rows = [];
foreach ($users as $user) {
$row = ['name' => $user->name];
if (isset($columns['team'])) {
$teamNames = $user->children->pluck('team.name')->filter()->unique()->implode(', ');
$row['team'] = $teamNames ?: '';
}
if (isset($columns['email'])) {
$row['email'] = $user->email;
}
if (isset($columns['phone'])) {
$row['phone'] = $user->phone ?? '';
}
if (isset($columns['children'])) {
$row['children'] = $user->children->map(fn ($c) => $c->first_name)->implode(', ') ?: '';
}
foreach ($columns as $key => $header) {
if (str_starts_with($key, 'custom_')) {
$row[$key] = '';
}
}
$rows[] = $row;
}
return $rows;
}
private function buildFreetextRows(array $data): array
{
$lines = array_filter(
array_map('trim', explode("\n", $data['freetext_rows'] ?? '')),
fn ($line) => $line !== ''
);
// Maximum 200 Zeilen — DoS-Schutz (V10)
$lines = array_slice($lines, 0, 200);
return array_map(fn ($line) => ['name' => $line], array_values($lines));
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Location;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class LocationController extends Controller
{
public function index(): View
{
$locations = Location::orderBy('name')->get();
return view('admin.locations.index', compact('locations'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'address_text' => ['nullable', 'string', 'max:500'],
'location_lat' => ['nullable', 'numeric', 'between:-90,90'],
'location_lng' => ['nullable', 'numeric', 'between:-180,180'],
]);
Location::create($validated);
return redirect()->route('admin.locations.index')
->with('success', __('admin.location_created'));
}
public function update(Request $request, Location $location): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'address_text' => ['nullable', 'string', 'max:500'],
'location_lat' => ['nullable', 'numeric', 'between:-90,90'],
'location_lng' => ['nullable', 'numeric', 'between:-180,180'],
]);
$location->update($validated);
return redirect()->route('admin.locations.index')
->with('success', __('admin.location_updated'));
}
public function destroy(Location $location): RedirectResponse
{
$location->delete();
return redirect()->route('admin.locations.index')
->with('success', __('admin.location_deleted'));
}
}

View File

@@ -0,0 +1,269 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\Player;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PlayerController extends Controller
{
public function index(Request $request)
{
$query = Player::with(['team', 'parents']);
// Team-Scoping: Coach sieht nur Spieler eigener Teams (V04)
$user = auth()->user();
if (!$user->isAdmin()) {
$teamIds = $user->isCoach()
? $user->coachTeams()->pluck('teams.id')
: $user->accessibleTeamIds();
$query->whereIn('team_id', $teamIds);
}
if ($request->filled('team_id')) {
$query->where('team_id', $request->team_id);
}
// Sortierung
$sortable = ['name', 'team', 'jersey_number', '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) {
'name' => $query->orderBy('last_name', $direction)->orderBy('first_name', $direction),
'team' => $query->orderBy(
Team::select('name')->whereColumn('teams.id', 'players.team_id'), $direction
),
'jersey_number' => $query->orderBy(DB::raw('CASE WHEN jersey_number IS NULL THEN 1 ELSE 0 END'))->orderBy('jersey_number', $direction),
'is_active' => $query->orderBy('is_active', $direction),
default => $query->orderBy('created_at', $direction),
};
$players = $query->paginate(20)->withQueryString();
$teams = Team::active()->orderBy('name')->get();
$trashedPlayers = Player::onlyTrashed()
->with('team')
->where('deleted_at', '>=', now()->subDays(7))
->latest('deleted_at')
->get();
return view('admin.players.index', compact('players', 'teams', 'trashedPlayers', 'sort', 'direction'));
}
public function create()
{
$teams = Team::active()->orderBy('name')->get();
return view('admin.players.create', compact('teams'));
}
public function store(Request $request)
{
$validated = $request->validate([
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'team_id' => ['required', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
$team = Team::find($value);
if (!$team || !$team->is_active) {
$fail(__('validation.exists', ['attribute' => $attribute]));
}
}],
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
'is_active' => ['boolean'],
'photo_permission' => ['boolean'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$validated['is_active'] = $request->boolean('is_active', true);
$validated['photo_permission'] = $request->boolean('photo_permission');
$player = Player::create($validated);
if ($player->is_active) {
Event::syncParticipantsForTeam($player->team_id, auth()->id());
}
ActivityLog::logWithChanges('created', __('admin.log_player_created', ['name' => $player->full_name]), 'Player', $player->id, null, ['name' => $player->full_name, 'team' => $player->team->name ?? $player->team_id]);
return redirect()->route('admin.players.index')
->with('success', __('admin.player_created'));
}
public function edit(Player $player)
{
$player->load('parents');
$teams = Team::active()->orderBy('name')->get();
$users = User::active()->orderBy('name')->get();
return view('admin.players.edit', compact('player', 'teams', 'users'));
}
public function update(Request $request, Player $player)
{
$validated = $request->validate([
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'team_id' => ['required', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
$team = Team::find($value);
if (!$team || !$team->is_active) {
$fail(__('validation.exists', ['attribute' => $attribute]));
}
}],
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
'photo_permission' => ['boolean'],
'notes' => ['nullable', 'string', 'max:2000'],
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
]);
$validated['photo_permission'] = $request->boolean('photo_permission');
// Handle profile picture upload
if ($request->hasFile('profile_picture')) {
if ($player->profile_picture) {
Storage::disk('public')->delete($player->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 = ['first_name' => $player->first_name, 'last_name' => $player->last_name, 'team_id' => $player->team_id, 'birth_year' => $player->birth_year, 'jersey_number' => $player->jersey_number, 'photo_permission' => $player->photo_permission];
$oldTeamId = $player->team_id;
$player->update($validated);
// Sync: neues Team bekommt den Spieler, bei Team-Wechsel auch altes Team
if ($player->is_active) {
Event::syncParticipantsForTeam($player->team_id, auth()->id());
}
if ($oldTeamId !== (int) $validated['team_id']) {
Event::syncParticipantsForTeam($oldTeamId, auth()->id());
}
$newData = ['first_name' => $player->first_name, 'last_name' => $player->last_name, 'team_id' => $player->team_id, 'birth_year' => $player->birth_year, 'jersey_number' => $player->jersey_number, 'photo_permission' => $player->photo_permission];
ActivityLog::logWithChanges('updated', __('admin.log_player_updated', ['name' => $player->full_name]), 'Player', $player->id, $oldData, $newData);
return redirect()->route('admin.players.index')
->with('success', __('admin.player_updated'));
}
public function quickUpdate(Request $request, Player $player)
{
$validated = $request->validate([
'team_id' => ['sometimes', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
$team = Team::find($value);
if (!$team || !$team->is_active) {
$fail(__('validation.exists', ['attribute' => $attribute]));
}
}],
'is_active' => ['sometimes', 'boolean'],
'photo_permission' => ['sometimes', 'boolean'],
]);
$oldTeamId = $player->team_id;
$player->update($validated);
// Sync future events when team or active status changes
if (isset($validated['team_id']) || isset($validated['is_active'])) {
if ($player->is_active) {
Event::syncParticipantsForTeam($player->team_id, auth()->id());
}
if (isset($validated['team_id']) && $oldTeamId !== (int) $validated['team_id']) {
Event::syncParticipantsForTeam($oldTeamId, auth()->id());
}
}
return response()->json(['success' => true]);
}
public function assignParent(Request $request, Player $player)
{
$validated = $request->validate([
'parent_id' => ['required', 'exists:users,id'],
'relationship_label' => ['nullable', 'string', 'max:50'],
]);
$player->parents()->syncWithoutDetaching([
$validated['parent_id'] => [
'relationship_label' => $validated['relationship_label'] ?? null,
'created_at' => now(),
],
]);
$parent = User::find($validated['parent_id']);
ActivityLog::logWithChanges('parent_assigned', __('admin.log_parent_assigned', ['parent' => $parent?->name, 'player' => $player->full_name]), 'Player', $player->id, null, ['parent' => $parent?->name, 'player' => $player->full_name]);
return back()->with('success', __('admin.parent_assigned'));
}
public function removeParent(Player $player, User $user)
{
$player->parents()->detach($user->id);
ActivityLog::logWithChanges('parent_removed', __('admin.log_parent_removed', ['parent' => $user->name, 'player' => $player->full_name]), 'Player', $player->id, ['parent' => $user->name, 'player' => $player->full_name], null);
return back()->with('success', __('admin.parent_removed'));
}
public function removePicture(Player $player): RedirectResponse
{
if ($player->profile_picture) {
Storage::disk('public')->delete($player->profile_picture);
$player->update(['profile_picture' => null]);
}
return back()->with('success', __('admin.picture_removed'));
}
public function toggleActive(Player $player): RedirectResponse
{
$oldActive = $player->is_active;
$player->update(['is_active' => !$player->is_active]);
// Sync future events
Event::syncParticipantsForTeam($player->team_id, auth()->id());
$status = $player->is_active ? __('admin.activated') : __('admin.deactivated');
ActivityLog::logWithChanges('toggled_active', __('admin.log_player_toggled', ['name' => $player->full_name, 'status' => $status]), 'Player', $player->id, ['is_active' => $oldActive], ['is_active' => $player->is_active]);
return back()->with('success', __('admin.player_toggled', ['status' => $status]));
}
public function destroy(Player $player): RedirectResponse
{
$player->delete();
ActivityLog::logWithChanges('deleted', __('admin.log_player_deleted', ['name' => $player->full_name]), 'Player', $player->id, ['name' => $player->full_name, 'team' => $player->team->name ?? ''], null);
return redirect()->route('admin.players.index')->with('success', __('admin.player_deleted'));
}
public function restore(int $id): RedirectResponse
{
$player = Player::onlyTrashed()->findOrFail($id);
if (! $player->isRestorable()) {
return back()->with('error', __('admin.restore_expired'));
}
$player->restore();
ActivityLog::logWithChanges('restored', __('admin.log_player_restored', ['name' => $player->full_name]), 'Player', $player->id, null, ['name' => $player->full_name]);
return back()->with('success', __('admin.player_restored'));
}
}

View File

@@ -0,0 +1,418 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\FileCategory;
use App\Models\Setting;
use App\Services\HtmlSanitizerService;
use App\Services\SupportApiService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class SettingsController extends Controller
{
public function __construct(private HtmlSanitizerService $sanitizer) {}
public function edit(): View
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
$allSettings = Setting::all()->keyBy('key');
// Event-Default-Keys separieren — immer alle liefern (auch wenn nicht in DB)
$eventDefaults = collect();
foreach (['home_game', 'away_game', 'training', 'tournament', 'meeting'] as $type) {
foreach (['players', 'catering', 'timekeepers'] as $field) {
$key = "default_min_{$field}_{$type}";
$eventDefaults[$key] = $allSettings[$key]->value ?? null;
}
}
// Visibility-Settings separieren
$visibilitySettings = $allSettings->filter(fn ($s) => str_starts_with($s->key, 'visibility_'));
$settings = $allSettings->filter(fn ($s) =>
!str_starts_with($s->key, 'default_min_') &&
!str_starts_with($s->key, 'visibility_') &&
!str_starts_with($s->key, 'impressum_html_') &&
!str_starts_with($s->key, 'datenschutz_html_') &&
!str_starts_with($s->key, 'password_reset_email_')
);
$fileCategories = FileCategory::ordered()->withCount('files')->get();
// Verfügbare Sprachen und deren locale-spezifische Settings
$availableLocales = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
$localeSettings = [];
foreach ($availableLocales as $locale) {
$localeSettings[$locale] = [
'impressum_html' => $allSettings["impressum_html_{$locale}"]->value ?? '',
'datenschutz_html' => $allSettings["datenschutz_html_{$locale}"]->value ?? '',
'password_reset_email' => $allSettings["password_reset_email_{$locale}"]->value ?? '',
];
}
// Support-API-Status (nur für Admin-Tab)
$supportService = app(SupportApiService::class);
$isRegistered = $supportService->isRegistered();
$installationId = $isRegistered ? ($supportService->readInstalled()['installation_id'] ?? null) : null;
$updateInfo = Cache::get('support.update_check');
$mailConfig = [
'mailer' => config('mail.default'),
'host' => config('mail.mailers.smtp.host'),
'port' => config('mail.mailers.smtp.port'),
'username' => config('mail.mailers.smtp.username'),
'password' => config('mail.mailers.smtp.password'),
'encryption' => config('mail.mailers.smtp.scheme', 'tls'),
'from_address' => config('mail.from.address'),
'from_name' => config('mail.from.name'),
];
return view('admin.settings.edit', compact(
'settings', 'eventDefaults', 'fileCategories', 'visibilitySettings',
'isRegistered', 'installationId', 'updateInfo',
'availableLocales', 'localeSettings', 'mailConfig'
));
}
public function update(Request $request): RedirectResponse
{
if (!auth()->user()->isAdmin()) {
abort(403, 'Nur Admins koennen Einstellungen aendern.');
}
// Favicon-Upload verarbeiten (vor der normalen Settings-Schleife)
if ($request->hasFile('favicon')) {
$request->validate([
'favicon' => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:512',
]);
// Altes Favicon löschen
$oldFavicon = Setting::get('app_favicon');
if ($oldFavicon) {
Storage::disk('public')->delete($oldFavicon);
}
$file = $request->file('favicon');
$filename = Str::uuid() . '.' . $file->guessExtension();
$path = $file->storeAs('favicon', $filename, 'public');
Setting::set('app_favicon', $path);
} elseif ($request->has('remove_favicon')) {
$oldFavicon = Setting::get('app_favicon');
if ($oldFavicon) {
Storage::disk('public')->delete($oldFavicon);
}
Setting::set('app_favicon', null);
}
$inputSettings = $request->input('settings', []);
// Whitelist: Nur erlaubte Setting-Keys akzeptieren
$allowedLocales = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
$allowedPrefixes = ['default_min_', 'visibility_'];
$allowedLocaleKeys = [];
foreach ($allowedLocales as $loc) {
$allowedLocaleKeys[] = "impressum_html_{$loc}";
$allowedLocaleKeys[] = "datenschutz_html_{$loc}";
$allowedLocaleKeys[] = "password_reset_email_{$loc}";
}
$oldValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
foreach ($inputSettings as $key => $value) {
// Whitelist-Pruefung: Nur bekannte Keys oder erlaubte Prefixe
$isExistingSetting = Setting::where('key', $key)->exists();
$isAllowedLocaleKey = in_array($key, $allowedLocaleKeys);
$isAllowedPrefix = false;
foreach ($allowedPrefixes as $prefix) {
if (str_starts_with($key, $prefix)) {
$isAllowedPrefix = true;
break;
}
}
if (!$isExistingSetting && !$isAllowedLocaleKey && !$isAllowedPrefix) {
continue; // Unbekannten Key ignorieren
}
$setting = Setting::where('key', $key)->first();
if ($setting) {
if ($setting->type === 'html' || $setting->type === 'richtext') {
$value = $this->sanitizer->sanitize($value ?? '');
} elseif ($setting->type === 'number') {
$value = $value !== null && $value !== '' ? (int) $value : null;
} else {
$value = strip_tags($value ?? '');
}
$setting->update(['value' => $value]);
} elseif ($isAllowedLocaleKey) {
// Locale-suffixed legal/email settings: upsert mit HTML-Sanitisierung
$value = $this->sanitizer->sanitize($value ?? '');
$localeSetting = Setting::where('key', $key)->first();
if ($localeSetting) {
$localeSetting->update(['value' => $value]);
} else {
$localeSetting = new Setting(['label' => $key, 'type' => 'html', 'value' => $value]);
$localeSetting->key = $key;
$localeSetting->save();
}
} elseif ($isAllowedPrefix) {
// Event-Defaults / Visibility: upsert — anlegen wenn nicht vorhanden
$prefixSetting = new Setting([
'label' => $key,
'type' => 'number',
'value' => $value !== null && $value !== '' ? (int) $value : null,
]);
$prefixSetting->key = $key;
$prefixSetting->save();
}
}
Setting::clearCache();
$newValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
ActivityLog::logWithChanges('updated', __('admin.log_settings_updated'), 'Setting', null, $oldValues, $newValues);
// License key validation when changed
$newLicenseKey = $inputSettings['license_key'] ?? null;
$oldLicenseKey = $oldValues['license_key'] ?? null;
if ($newLicenseKey && $newLicenseKey !== $oldLicenseKey) {
$supportService = app(SupportApiService::class);
$result = $supportService->validateLicense($newLicenseKey);
if ($result && !($result['valid'] ?? false)) {
session()->flash('warning', __('admin.license_invalid'));
}
}
return back()->with('success', __('admin.settings_saved'));
}
public function updateMail(Request $request): RedirectResponse
{
if (! auth()->user()->isAdmin()) {
abort(403);
}
$mailer = $request->input('mail_mailer', 'log');
if ($mailer === 'smtp') {
$request->validate([
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_username' => 'required|string|max:255',
'mail_password' => 'required|string|max:255',
'mail_from_address' => 'required|email|max:255',
'mail_from_name' => 'nullable|string|max:255',
'mail_encryption' => 'required|in:tls,ssl,none',
]);
$encryption = $request->input('mail_encryption');
$this->updateEnvValues([
'MAIL_MAILER' => 'smtp',
'MAIL_HOST' => $request->input('mail_host'),
'MAIL_PORT' => $request->input('mail_port'),
'MAIL_USERNAME' => $request->input('mail_username'),
'MAIL_PASSWORD' => $request->input('mail_password'),
'MAIL_FROM_ADDRESS' => $request->input('mail_from_address'),
'MAIL_FROM_NAME' => $request->input('mail_from_name', config('app.name')),
'MAIL_SCHEME' => $encryption === 'none' ? '' : $encryption,
]);
} else {
$this->updateEnvValues([
'MAIL_MAILER' => 'log',
]);
}
Artisan::call('config:clear');
return back()->with('success', __('admin.mail_saved'))->withFragment('mail');
}
public function testMail(Request $request): \Illuminate\Http\JsonResponse
{
if (! auth()->user()->isAdmin()) {
return response()->json(['success' => false, 'message' => 'Keine Berechtigung.'], 403);
}
$request->validate([
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_username' => 'required|string|max:255',
'mail_password' => 'required|string|max:255',
'mail_encryption' => 'required|in:tls,ssl,none',
]);
try {
$encryption = $request->input('mail_encryption');
$tls = ($encryption !== 'none');
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$request->input('mail_host'),
(int) $request->input('mail_port'),
$tls,
);
$transport->setUsername($request->input('mail_username'));
$transport->setPassword($request->input('mail_password'));
$transport->start();
$transport->stop();
return response()->json(['success' => true, 'message' => __('admin.mail_test_success')]);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
private function updateEnvValues(array $values): void
{
$envPath = base_path('.env');
$envContent = file_get_contents($envPath);
foreach ($values as $key => $value) {
if ($value === '' || $value === null) {
$replacement = "# {$key}=";
} else {
$quotedValue = str_contains($value, ' ') || str_contains($value, '#')
? '"' . str_replace('"', '\\"', $value) . '"'
: $value;
$replacement = "{$key}={$quotedValue}";
}
if (preg_match("/^#?\s*{$key}=.*/m", $envContent)) {
$envContent = preg_replace("/^#?\s*{$key}=.*/m", $replacement, $envContent);
} else {
$envContent .= "\n{$replacement}";
}
}
file_put_contents($envPath, $envContent);
}
public function destroyDemoData(Request $request): RedirectResponse
{
if (! auth()->user()->isAdmin()) {
abort(403);
}
$request->validate([
'password' => ['required', 'current_password'],
]);
// Löschreihenfolge beachtet FK-Constraints
DB::table('activity_logs')->delete();
DB::table('comments')->delete();
DB::table('event_participants')->delete();
DB::table('event_catering')->delete();
DB::table('event_timekeepers')->delete();
DB::table('event_faq')->delete();
DB::table('event_file')->delete();
DB::table('events')->delete();
DB::table('parent_player')->delete();
DB::table('players')->delete();
DB::table('team_user')->delete();
DB::table('team_file')->delete();
DB::table('teams')->delete();
DB::table('invitation_players')->delete();
DB::table('invitations')->delete();
DB::table('locations')->delete();
DB::table('faq')->delete();
DB::table('users')->where('id', '!=', auth()->id())->delete();
// Hochgeladene Dateien aus Storage entfernen + DB-Einträge löschen
$files = DB::table('files')->get();
foreach ($files as $file) {
Storage::disk('private')->delete($file->path);
}
DB::table('files')->delete();
// Profilbilder-Ordner leeren (Admin-Bild bleibt via DB erhalten)
$adminAvatar = auth()->user()->profile_picture;
foreach (Storage::disk('public')->files('avatars') as $avatarFile) {
if ($adminAvatar && str_contains($avatarFile, $adminAvatar)) {
continue;
}
Storage::disk('public')->delete($avatarFile);
}
ActivityLog::log('deleted', __('admin.demo_data_deleted'));
return redirect()->route('admin.settings.edit')
->with('success', __('admin.demo_data_deleted'));
}
public function factoryReset(Request $request): RedirectResponse
{
if (! auth()->user()->isAdmin()) {
abort(403);
}
$request->validate([
'password' => ['required', 'current_password'],
'confirmation' => ['required', 'in:RESET-BESTÄTIGT'],
]);
// 1. Alle hochgeladenen Dateien entfernen
Storage::disk('private')->deleteDirectory('files');
Storage::disk('public')->deleteDirectory('avatars');
Storage::disk('public')->deleteDirectory('favicon');
Storage::disk('public')->deleteDirectory('dsgvo');
// 2. FK-Constraints deaktivieren (DB-agnostisch)
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
DB::statement('PRAGMA foreign_keys = OFF;');
} else {
DB::statement('SET FOREIGN_KEY_CHECKS = 0;');
}
// 3. Alle Tabellen leeren
$tables = [
'activity_logs', 'comments', 'event_participants',
'event_catering', 'event_timekeepers', 'event_faq',
'event_file', 'events', 'parent_player', 'players',
'team_user', 'team_file', 'teams',
'invitation_players', 'invitations', 'locations',
'faq', 'files', 'file_categories', 'settings',
'users', 'sessions', 'cache', 'cache_locks',
];
foreach ($tables as $table) {
DB::table($table)->delete();
}
// 4. FK-Constraints reaktivieren
if ($driver === 'sqlite') {
DB::statement('PRAGMA foreign_keys = ON;');
} else {
DB::statement('SET FOREIGN_KEY_CHECKS = 1;');
}
// 5. storage/installed entfernen → Installer-Modus aktivieren
$installedFile = storage_path('installed');
if (file_exists($installedFile)) {
unlink($installedFile);
}
// 6. Caches leeren
Artisan::call('cache:clear');
Artisan::call('config:clear');
Artisan::call('view:clear');
Artisan::call('route:clear');
// 7. Session invalidieren + Logout
auth()->logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
// 8. Redirect zum Installer
return redirect()->route('install.requirements');
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventCatering;
use App\Models\EventParticipant;
use App\Models\EventTimekeeper;
use App\Models\Player;
use App\Models\Setting;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class StatisticsController extends Controller
{
public function index(Request $request): View
{
if (!Setting::isFeatureVisibleFor('statistics', auth()->user())) {
abort(403);
}
$request->validate([
'team_id' => ['nullable', 'integer', 'exists:teams,id'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
]);
$query = Event::with(['team'])
->withCount([
'participants as players_yes_count' => fn ($q) => $q->where('status', 'yes'),
'caterings as caterings_yes_count' => fn ($q) => $q->where('status', 'yes'),
'timekeepers as timekeepers_yes_count' => fn ($q) => $q->where('status', 'yes'),
])
->whereIn('type', [EventType::HomeGame, EventType::AwayGame])
->where('status', EventStatus::Published);
if ($request->filled('team_id')) {
$query->where('team_id', $request->team_id);
}
if ($request->filled('from')) {
$query->where('start_at', '>=', $request->from);
}
if ($request->filled('to')) {
$query->where('start_at', '<=', $request->to . ' 23:59:59');
}
$games = $query->orderByDesc('start_at')->get();
// Statistiken berechnen
$gamesWithScore = $games->filter(fn ($g) => $g->score_home !== null && $g->score_away !== null);
$wins = 0;
$losses = 0;
$draws = 0;
foreach ($gamesWithScore as $game) {
if ($game->type === EventType::HomeGame) {
if ($game->score_home > $game->score_away) {
$wins++;
} elseif ($game->score_home < $game->score_away) {
$losses++;
} else {
$draws++;
}
} else {
if ($game->score_away > $game->score_home) {
$wins++;
} elseif ($game->score_away < $game->score_home) {
$losses++;
} else {
$draws++;
}
}
}
$totalWithScore = $gamesWithScore->count();
$winRate = $totalWithScore > 0 ? round(($wins / $totalWithScore) * 100) : 0;
// Chart-Daten
$chartWinLoss = [
'labels' => [__('admin.wins'), __('admin.losses'), __('admin.draws')],
'data' => [$wins, $losses, $draws],
'colors' => ['#22c55e', '#ef4444', '#9ca3af'],
];
// Spieler-Teilnahme pro Spiel (nur die letzten 15 Spiele)
$recentGames = $games->take(15)->reverse()->values();
$chartPlayerParticipation = [
'labels' => $recentGames->map(fn ($g) => $g->start_at->format('d.m.'))->toArray(),
'data' => $recentGames->map(fn ($g) => $g->players_yes_count)->toArray(),
];
// Eltern-Engagement (Catering + Zeitnehmer)
$chartParentInvolvement = [
'labels' => $recentGames->map(fn ($g) => $g->start_at->format('d.m.'))->toArray(),
'catering' => $recentGames->map(fn ($g) => $g->caterings_yes_count)->toArray(),
'timekeepers' => $recentGames->map(fn ($g) => $g->timekeepers_yes_count)->toArray(),
];
$teams = Team::where('is_active', true)->orderBy('name')->get();
// ── Spieler-Rangliste ──────────────────────────────────
$gameIds = $games->pluck('id');
$totalGames = $games->count();
$playerRanking = collect();
if ($totalGames > 0) {
$playerRanking = EventParticipant::select('player_id', DB::raw('COUNT(*) as total_assigned'), DB::raw('SUM(CASE WHEN status = \'yes\' THEN 1 ELSE 0 END) as games_played'))
->whereIn('event_id', $gameIds)
->whereNotNull('player_id')
->groupBy('player_id')
->get()
->map(function ($row) use ($totalGames) {
$player = Player::withTrashed()->find($row->player_id);
if (!$player) {
return null;
}
return (object) [
'player' => $player,
'games_played' => (int) $row->games_played,
'total_assigned' => (int) $row->total_assigned,
'total_games' => $totalGames,
'rate' => $row->total_assigned > 0
? round(($row->games_played / $row->total_assigned) * 100)
: 0,
];
})
->filter()
->sortByDesc('games_played')
->values();
}
// ── Eltern-Engagement-Rangliste ────────────────────────
// Alle publizierten Events (nicht nur Spiele) mit gleichen Team/Datum-Filtern
$allEventsQuery = Event::where('status', EventStatus::Published);
if ($request->filled('team_id')) {
$allEventsQuery->where('team_id', $request->team_id);
}
if ($request->filled('from')) {
$allEventsQuery->where('start_at', '>=', $request->from);
}
if ($request->filled('to')) {
$allEventsQuery->where('start_at', '<=', $request->to . ' 23:59:59');
}
$allEventIds = $allEventsQuery->pluck('id');
// Catering-Events (nur Typen die Catering haben)
$cateringEventIds = $allEventsQuery->clone()
->whereNotIn('type', [EventType::AwayGame, EventType::Meeting])
->pluck('id');
// Zeitnehmer-Events (identisch wie Catering)
$timekeeperEventIds = $cateringEventIds;
$cateringCounts = EventCatering::select('user_id', DB::raw('COUNT(*) as count'))
->whereIn('event_id', $cateringEventIds)
->where('status', CateringStatus::Yes)
->groupBy('user_id')
->pluck('count', 'user_id');
$timekeeperCounts = EventTimekeeper::select('user_id', DB::raw('COUNT(*) as count'))
->whereIn('event_id', $timekeeperEventIds)
->where('status', CateringStatus::Yes)
->groupBy('user_id')
->pluck('count', 'user_id');
$parentUserIds = $cateringCounts->keys()->merge($timekeeperCounts->keys())->unique();
$parentRanking = User::withTrashed()
->whereIn('id', $parentUserIds)
->get()
->map(function ($user) use ($cateringCounts, $timekeeperCounts) {
$catering = $cateringCounts->get($user->id, 0);
$timekeeper = $timekeeperCounts->get($user->id, 0);
return (object) [
'user' => $user,
'catering_count' => $catering,
'timekeeper_count' => $timekeeper,
'total' => $catering + $timekeeper,
];
})
->sortByDesc('total')
->values();
$totalCateringEvents = $cateringEventIds->count();
$totalTimekeeperEvents = $timekeeperEventIds->count();
return view('admin.statistics.index', compact(
'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore',
'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement',
'playerRanking', 'totalGames',
'parentRanking', 'totalCateringEvents', 'totalTimekeeperEvents'
));
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\SupportApiService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SupportController extends Controller
{
public function __construct(private SupportApiService $supportService) {}
/**
* Nur Admins duerfen den Support-Bereich nutzen (T05).
*/
private function authorizeAdmin(): void
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
}
public function index(): View
{
$this->authorizeAdmin();
$registered = $this->supportService->isRegistered();
$tickets = $registered ? ($this->supportService->getTickets() ?? []) : [];
return view('admin.support.index', compact('registered', 'tickets'));
}
public function show(int $ticketId): View|RedirectResponse
{
$this->authorizeAdmin();
if (!$this->supportService->isRegistered()) {
return redirect()->route('admin.support.index');
}
$ticket = $this->supportService->getTicket($ticketId);
if (!$ticket) {
return redirect()->route('admin.support.index')
->with('error', __('admin.support_ticket_not_found'));
}
return view('admin.support.show', compact('ticket'));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAdmin();
$request->validate([
'subject' => 'required|string|max:255',
'message' => 'required|string|max:5000',
'category' => 'required|string|in:bug,feature,question,other',
]);
$result = $this->supportService->createTicket([
'subject' => $request->input('subject'),
'message' => $request->input('message'),
'category' => $request->input('category'),
'system_info' => $this->supportService->getSystemInfo(),
'license_key' => Setting::get('license_key'),
]);
if (!$result) {
return back()->withInput()
->with('error', __('admin.support_submit_failed'));
}
return redirect()->route('admin.support.show', $result['ticket_id'] ?? 0)
->with('success', __('admin.support_ticket_created'));
}
public function reply(Request $request, int $ticketId): RedirectResponse
{
$this->authorizeAdmin();
$request->validate([
'message' => 'required|string|max:5000',
]);
$result = $this->supportService->replyToTicket($ticketId, [
'message' => $request->input('message'),
]);
if (!$result) {
return back()->withInput()
->with('error', __('admin.support_reply_failed'));
}
return redirect()->route('admin.support.show', $ticketId)
->with('success', __('admin.support_reply_sent'));
}
public function register(): RedirectResponse
{
$this->authorizeAdmin();
$data = [
'app_name' => Setting::get('app_name', config('app.name')),
'app_url' => config('app.url'),
'app_version' => config('app.version'),
'php_version' => PHP_VERSION,
'db_driver' => config('database.default'),
'installed_at' => $this->supportService->readInstalled()['installed_at'] ?? now()->toIso8601String(),
];
$logoUrl = $this->supportService->getLogoUrl();
if ($logoUrl) {
$data['logo_url'] = $logoUrl;
}
$result = $this->supportService->register($data);
if ($result) {
return back()->with('success', __('admin.registration_success'));
}
return back()->with('error', __('admin.registration_failed'));
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\File;
use App\Models\FileCategory;
use App\Models\Player;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class TeamController extends Controller
{
public function index(): View
{
$teams = Team::withCount(['players', 'events'])->latest()->paginate(20);
return view('admin.teams.index', compact('teams'));
}
public function create(): View
{
return view('admin.teams.create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'year_group' => ['nullable', 'string', 'max:20'],
'is_active' => ['boolean'],
]);
$validated['is_active'] = $request->boolean('is_active', true);
Team::create($validated);
return redirect()->route('admin.teams.index')
->with('success', __('admin.team_created'));
}
public function edit(Team $team): View
{
$team->load([
'coaches',
'players' => fn ($q) => $q->orderBy('last_name'),
'files.category',
]);
$allCoaches = User::where('role', UserRole::Coach)
->where('is_active', true)
->orderBy('name')
->get();
$parentReps = $team->parentReps();
$allTeams = Team::active()->orderBy('name')->get();
$fileCategories = FileCategory::active()->ordered()
->with(['files' => fn ($q) => $q->latest()])
->get();
return view('admin.teams.edit', compact(
'team', 'allCoaches', 'parentReps', 'allTeams', 'fileCategories'
));
}
public function update(Request $request, Team $team): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'year_group' => ['nullable', 'string', 'max:20'],
'is_active' => ['boolean'],
'notes' => ['nullable', 'string', 'max:5000'],
'coach_ids' => ['nullable', 'array'],
'coach_ids.*' => ['integer', 'exists:users,id', function ($attr, $value, $fail) {
$user = User::find($value);
if (!$user || $user->role !== \App\Enums\UserRole::Coach) {
$fail(__('validation.exists', ['attribute' => $attr]));
}
}],
'existing_files' => ['nullable', 'array'],
'existing_files.*' => ['integer', 'exists:files,id'],
'new_files' => ['nullable', 'array'],
'new_files.*' => ['file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
'new_file_categories' => ['nullable', 'array'],
'new_file_categories.*' => ['integer', 'exists:file_categories,id'],
]);
$oldData = [
'name' => $team->name,
'year_group' => $team->year_group,
'is_active' => $team->is_active,
'notes' => $team->notes,
];
$team->update([
'name' => $request->input('name'),
'year_group' => $request->input('year_group'),
'is_active' => $request->boolean('is_active', true),
'notes' => $request->input('notes'),
]);
// Trainer-Zuordnung sync
$coachIds = $request->input('coach_ids', []);
$team->coaches()->sync(array_map('intval', $coachIds));
// Dateien sync
$this->syncTeamFiles($team, $request);
$newData = [
'name' => $team->name,
'year_group' => $team->year_group,
'is_active' => $team->is_active,
'notes' => $team->notes,
];
ActivityLog::logWithChanges('updated', __('admin.log_team_updated', ['name' => $team->name]), 'Team', $team->id, $oldData, $newData);
return redirect()->route('admin.teams.edit', $team)
->with('success', __('admin.team_updated'));
}
public function updatePlayerTeam(Request $request, Team $team): JsonResponse
{
$validated = $request->validate([
'player_id' => ['required', 'integer', 'exists:players,id'],
'new_team_id' => ['required', 'integer', 'exists:teams,id'],
]);
$player = Player::findOrFail($validated['player_id']);
// Spieler muss aktuell im Route-Team sein
if ($player->team_id !== $team->id) {
return response()->json(['error' => 'Forbidden'], 403);
}
// Ziel-Team muss existieren und aktiv sein
$newTeam = Team::where('id', $validated['new_team_id'])->where('is_active', true)->first();
if (!$newTeam) {
return response()->json(['error' => 'Ziel-Team nicht gefunden oder inaktiv'], 422);
}
$oldTeamId = $player->team_id;
$player->update(['team_id' => $newTeam->id]);
ActivityLog::logWithChanges(
'updated',
__('admin.log_player_team_changed', ['name' => $player->full_name]),
'Player',
$player->id,
['team_id' => $oldTeamId],
['team_id' => (int) $validated['new_team_id']]
);
return response()->json(['success' => true]);
}
private function syncTeamFiles(Team $team, Request $request): void
{
$existingFileIds = $request->input('existing_files', []);
$newFileIds = [];
$newFiles = $request->file('new_files', []);
$newCategories = $request->input('new_file_categories', []);
foreach ($newFiles as $index => $uploadedFile) {
if (!$uploadedFile || !$uploadedFile->isValid()) {
continue;
}
$categoryId = $newCategories[$index] ?? null;
if (!$categoryId) {
continue;
}
$extension = $uploadedFile->guessExtension();
$storedName = Str::uuid() . '.' . $extension;
Storage::disk('local')->putFileAs('files', $uploadedFile, $storedName);
$file = new File([
'file_category_id' => $categoryId,
'original_name' => $uploadedFile->getClientOriginalName(),
'mime_type' => $uploadedFile->getClientMimeType(),
'size' => $uploadedFile->getSize(),
]);
$file->stored_name = $storedName;
$file->disk = 'private';
$file->uploaded_by = auth()->id();
$file->save();
$newFileIds[] = $file->id;
}
$allFileIds = array_merge(
array_map('intval', $existingFileIds),
$newFileIds
);
$team->files()->sync($allFileIds);
}
}

View 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]
);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Auth;
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\Log;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class ForgotPasswordController extends Controller
{
public function showForm(): View
{
return view('auth.forgot-password');
}
public function sendResetLink(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// Deaktivierte Benutzer: keinen Reset-Link senden, aber generische Meldung zurückgeben (V01)
$user = User::where('email', $request->email)->first();
if ($user && !$user->is_active) {
return back()->with('status', __('passwords.sent'));
}
try {
$status = Password::sendResetLink(
$request->only('email')
);
if ($status === Password::RESET_LINK_SENT) {
ActivityLog::log('password_reset_requested', __('admin.log_password_reset_requested'));
}
} catch (\Exception $e) {
Log::error('Password reset mail failed', ['error' => $e->getMessage()]);
// Pruefen ob Mail ueberhaupt konfiguriert ist
$mailer = config('mail.default');
if ($mailer === 'smtp') {
$hint = 'SMTP-Zugangsdaten in der .env-Datei pruefen (MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD).';
} elseif ($mailer === 'log') {
$hint = 'MAIL_MAILER=log — E-Mails werden nur ins Log geschrieben, nicht versendet. Fuer echten Versand SMTP konfigurieren.';
} else {
$hint = 'Mail-Konfiguration pruefen (MAIL_MAILER=' . $mailer . ').';
}
return back()->withErrors([
'email' => 'E-Mail konnte nicht gesendet werden: ' . $e->getMessage() . ' — ' . $hint,
]);
}
// Immer dieselbe Erfolgsmeldung zurueckgeben (Email-Enumeration verhindern)
return back()->with('status', __('passwords.sent'));
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function showForm()
{
return view('auth.login');
}
public function login(Request $request)
{
// Honeypot — Bots füllen versteckte Felder aus
if ($request->filled('website')) {
ActivityLog::log('bot_blocked', 'Bot blocked on login (honeypot triggered)');
return back()
->withInput($request->only('email'))
->withErrors(['email' => __('auth_ui.login_failed')]);
}
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
// Deaktivierte Benutzer dürfen sich nicht einloggen (V01)
$user = User::where('email', $request->email)->first();
if ($user && !$user->is_active) {
return back()
->withInput($request->only('email'))
->withErrors(['email' => __('auth_ui.login_failed')]);
}
if (!Auth::attempt($credentials, $request->boolean('remember'))) {
$maskedEmail = $this->maskEmail($request->email);
ActivityLog::log('login_failed', __('admin.log_login_failed', ['email' => $maskedEmail]));
return back()
->withInput($request->only('email'))
->withErrors(['email' => __('auth_ui.login_failed')]);
}
$request->session()->regenerate();
$request->user()->last_login_at = now();
$request->user()->save();
ActivityLog::log('login', __('admin.log_login', ['name' => $request->user()->name]), 'User', $request->user()->id);
return redirect()->intended(route('dashboard'));
}
public function logout(Request $request)
{
ActivityLog::log('logout', __('admin.log_logout', ['name' => $request->user()->name]), 'User', $request->user()->id);
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
private function maskEmail(string $email): string
{
$parts = explode('@', $email, 2);
if (count($parts) !== 2) {
return '***';
}
$local = $parts[0];
$masked = mb_substr($local, 0, 2) . str_repeat('*', max(mb_strlen($local) - 2, 2));
return $masked . '@' . $parts[1];
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Invitation;
use App\Services\InvitationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class RegisterController extends Controller
{
public function __construct(private InvitationService $invitationService) {}
public function showForm(string $token): View|RedirectResponse
{
$invitation = Invitation::with('players.team')->where('token', hash('sha256', $token))->first();
if (!$invitation || !$invitation->isValid()) {
return redirect()->route('login')
->with('error', __('auth_ui.invalid_invitation'));
}
return view('auth.register', compact('invitation'));
}
public function register(Request $request, string $token): RedirectResponse
{
$invitation = Invitation::with('players')->where('token', hash('sha256', $token))->first();
if (!$invitation || !$invitation->isValid()) {
return redirect()->route('login')
->with('error', __('auth_ui.invalid_invitation'));
}
// Honeypot — Bots füllen versteckte Felder aus
if ($request->filled('website')) {
return redirect()->route('login');
}
// E-Mail-Normalisierung vor Validierung (V17)
$request->merge(['email' => strtolower(trim($request->input('email')))]);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', Password::min(8)->letters()->numbers(), 'confirmed'],
]);
// E-Mail muss mit Einladung übereinstimmen (falls eingeschränkt)
if ($invitation->email && strtolower($validated['email']) !== strtolower($invitation->email)) {
return back()->withInput()->withErrors([
'email' => __('auth_ui.email_must_match_invitation', ['email' => $invitation->email]),
]);
}
$user = $this->invitationService->redeemInvitation($invitation, $validated);
Auth::login($user);
$request->session()->regenerate();
ActivityLog::log('registered', __('admin.log_registered', ['name' => $user->name]), 'User', $user->id);
return redirect()->route('dashboard')
->with('success', __('auth_ui.welcome'));
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\View\View;
class ResetPasswordController extends Controller
{
public function showResetForm(Request $request, string $token): View
{
return view('auth.reset-password', [
'token' => $token,
'email' => $request->query('email', ''),
]);
}
public function reset(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(8)->letters()->numbers()],
]);
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill([
'password' => $password,
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
ActivityLog::log(
'password_changed',
__('admin.log_password_changed_self', ['name' => $user->name]),
'User',
$user->id
);
}
);
if ($status === Password::PASSWORD_RESET) {
return redirect()->route('login')
->with('status', __($status));
}
// Generische Fehlermeldung — verhindert Email-Enumeration (T03)
return back()
->withInput($request->only('email'))
->withErrors(['email' => __('passwords.token')]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\EventCatering;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CateringController extends Controller
{
public function update(Request $request, Event $event): RedirectResponse
{
$user = auth()->user();
if ($event->status === EventStatus::Cancelled) {
abort(403);
}
if (!$event->type->hasCatering()) {
abort(403);
}
if (!$user->canAccessAdminPanel()) {
if ($event->status === EventStatus::Draft) {
abort(403);
}
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
abort(403);
}
}
$request->validate([
'status' => 'required|in:yes,no,unknown',
'note' => 'nullable|string|max:255',
]);
$existing = EventCatering::where('event_id', $event->id)->where('user_id', auth()->id())->first();
$oldStatus = $existing?->status?->value ?? 'unknown';
$catering = EventCatering::where('event_id', $event->id)->where('user_id', auth()->id())->first();
if (!$catering) {
$catering = new EventCatering(['event_id' => $event->id]);
$catering->user_id = auth()->id();
}
$catering->status = CateringStatus::from($request->status);
$catering->note = $request->note;
$catering->save();
ActivityLog::logWithChanges('status_changed', __('admin.log_catering_changed', ['event' => $event->title, 'status' => $request->status]), 'Event', $event->id, ['status' => $oldStatus, 'user_id' => auth()->id(), 'source' => 'catering'], ['status' => $request->status, 'user_id' => auth()->id(), 'source' => 'catering']);
return redirect(route('events.show', $event) . '#catering');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EventStatus;
use App\Models\ActivityLog;
use App\Models\Event;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CommentController extends Controller
{
public function store(Request $request, Event $event): RedirectResponse
{
$user = auth()->user();
if ($event->status === EventStatus::Cancelled) {
abort(403);
}
if (!$user->canAccessAdminPanel()) {
if ($event->status === EventStatus::Draft) {
abort(403);
}
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
abort(403);
}
}
$request->validate([
'body' => 'required|string|max:1000',
]);
$comment = $event->comments()->make(['body' => $request->body]);
$comment->user_id = auth()->id();
$comment->save();
ActivityLog::log('created', __('admin.log_comment_created', ['event' => $event->title]), 'Event', $event->id);
return redirect(route('events.show', $event) . '#comments');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ParticipantStatus;
use App\Models\Event;
class DashboardController extends Controller
{
public function index()
{
$user = auth()->user();
$query = Event::with(['team', 'participants'])
->withCount([
'caterings as caterings_yes_count' => fn ($q) => $q->where('status', 'yes'),
'timekeepers as timekeepers_yes_count' => fn ($q) => $q->where('status', 'yes'),
])
->published();
if (! $user->canAccessAdminPanel()) {
$query->whereIn('team_id', $user->accessibleTeamIds());
}
// Alle Events für den Kalender
$calendarEvents = $query->orderBy('start_at')->get()
->map(fn (Event $e) => [
'id' => $e->id,
'title' => $e->title,
'type' => $e->type->value,
'typeLabel' => $e->type->label(),
'date' => $e->start_at->format('Y-m-d'),
'time' => $e->start_at->format('H:i'),
'team' => $e->team->name,
'url' => route('events.show', $e),
'tl' => [
'y' => $e->participants->where('status', ParticipantStatus::Yes)->count(),
'n' => $e->participants->where('status', ParticipantStatus::No)->count(),
'o' => $e->participants->where('status', ParticipantStatus::Unknown)->count(),
],
'minMet' => $e->minimumsStatus(),
]);
return view('dashboard', compact('calendarEvents'));
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\Setting;
use App\Models\Team;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EventController extends Controller
{
public function index(Request $request): View
{
$user = auth()->user();
$query = Event::with(['team', 'participants'])
->withCount([
'caterings as caterings_yes_count' => fn ($q) => $q->where('status', 'yes'),
'timekeepers as timekeepers_yes_count' => fn ($q) => $q->where('status', 'yes'),
]);
// Admins sehen auch Entwürfe, Eltern nur published
if ($user->canAccessAdminPanel()) {
$teams = Team::where('is_active', true)->orderBy('name')->get();
} else {
$teamIds = $user->accessibleTeamIds();
$query->published()->whereIn('team_id', $teamIds);
$teams = Team::whereIn('id', $teamIds)->orderBy('name')->get();
}
// Filter: Team (nur Integer-IDs akzeptieren)
if ($request->filled('team_id')) {
$query->where('team_id', (int) $request->team_id);
}
// Filter: Typ (nur gueltige EventType-Werte)
if ($request->filled('type')) {
$validTypes = array_column(EventType::cases(), 'value');
if (in_array($request->type, $validTypes)) {
$query->where('type', $request->type);
}
}
// Filter: Zeitraum
if ($request->input('period') === 'past') {
$query->where('start_at', '<', now())->orderByDesc('start_at');
} else {
$query->where('start_at', '>=', now())->orderBy('start_at');
}
$events = $query->paginate(15)->withQueryString();
return view('events.index', compact('events', 'teams'));
}
public function show(Event $event): View
{
$user = auth()->user();
// Entwürfe nur für Admins
if ($event->status === EventStatus::Draft && !$user->canAccessAdminPanel()) {
abort(403);
}
// Kinder einmal laden, für Zugriffsprüfung + Teilnahme-Buttons
$userChildren = $user->children()->select('players.id', 'players.team_id')->get();
// Zugriffsbeschraenkung: User muss Zugang zum Team haben (ueber accessibleTeamIds)
if (!$user->canAccessAdminPanel()) {
$accessibleTeamIds = $user->accessibleTeamIds();
if (!$accessibleTeamIds->contains($event->team_id)) {
abort(403);
}
}
$event->syncParticipants($user->id);
$isMeeting = $event->type === EventType::Meeting;
$relations = ['team', 'comments.user', 'files.category'];
$relations[] = $isMeeting ? 'participants.user' : 'participants.player';
$relations[] = 'participants.setByUser';
if ($event->type->hasCatering()) {
$relations[] = 'caterings.user';
}
if ($event->type->hasTimekeepers()) {
$relations[] = 'timekeepers.user';
}
$event->load($relations);
$userChildIds = $userChildren->pluck('id');
// Eigener Catering-Status
$myCatering = $event->type->hasCatering()
? $event->caterings->where('user_id', $user->id)->first()
: null;
// Eigener Zeitnehmer-Status
$myTimekeeper = $event->type->hasTimekeepers()
? $event->timekeepers->where('user_id', $user->id)->first()
: null;
// Catering/Zeitnehmer-Verlauf für Staff (chronologische Statusänderungen)
$cateringHistory = collect();
$timekeeperHistory = collect();
if ($user->canAccessAdminPanel() && Setting::isFeatureVisibleFor('catering_history', $user)) {
$statusLogs = ActivityLog::where('model_type', 'Event')
->where('model_id', $event->id)
->where('action', 'status_changed')
->orderBy('created_at')
->get();
$cateringHistory = $statusLogs->filter(
fn ($log) => ($log->properties['new']['source'] ?? null) === 'catering'
);
$timekeeperHistory = $statusLogs->filter(
fn ($log) => ($log->properties['new']['source'] ?? null) === 'timekeeper'
);
}
return view('events.show', compact('event', 'userChildIds', 'myCatering', 'myTimekeeper', 'cateringHistory', 'timekeeperHistory'));
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers;
use App\Models\File;
use App\Models\FileCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FileController extends Controller
{
public function index(Request $request): View
{
$categories = FileCategory::active()
->ordered()
->withCount('files')
->get();
$activeCategory = $request->query('category');
$query = File::with(['category', 'uploader'])
->whereHas('category', fn ($q) => $q->where('is_active', true))
->latest();
if ($activeCategory) {
$query->whereHas('category', fn ($q) => $q->where('slug', $activeCategory));
}
// Nicht-Staff-Nutzer sehen nur Dateien ohne Team-Zuordnung oder aus eigenen Teams
$user = auth()->user();
if (!$user->canAccessAdminPanel()) {
$userTeamIds = $user->accessibleTeamIds();
$query->where(function ($q) use ($userTeamIds) {
$q->whereDoesntHave('teams')
->orWhereHas('teams', fn ($tq) => $tq->whereIn('teams.id', $userTeamIds));
});
}
$files = $query->paginate(25)->withQueryString();
return view('files.index', compact('categories', 'files', 'activeCategory'));
}
public function download(File $file): StreamedResponse
{
$this->authorizeFileAccess($file);
$path = $file->getStoragePath();
if (!Storage::disk('local')->exists($path)) {
abort(404);
}
return Storage::disk('local')->download($path, $file->original_name);
}
public function preview(File $file)
{
$this->authorizeFileAccess($file);
if (!$file->isImage() && !$file->isPdf()) {
abort(404);
}
$path = $file->getStoragePath();
if (!Storage::disk('local')->exists($path)) {
abort(404);
}
return response()->file(
Storage::disk('local')->path($path),
['Content-Type' => $file->mime_type]
);
}
/**
* Autorisierung: User muss Zugriff auf die Datei-Kategorie haben.
* Admins/Coaches duerfen alles, Eltern nur Dateien aus aktiven Kategorien
* die einem ihrer Teams zugeordnet sind oder allgemein verfuegbar sind.
*/
private function authorizeFileAccess(File $file): void
{
$user = auth()->user();
// Staff darf alles
if ($user->canAccessAdminPanel()) {
return;
}
// Datei muss zu einer aktiven Kategorie gehoeren
if (!$file->category || !$file->category->is_active) {
abort(403);
}
// Pruefen ob Datei einem Team zugeordnet ist, auf das der User Zugriff hat
$userTeamIds = $user->accessibleTeamIds();
$fileTeamIds = $file->teams()->pluck('teams.id');
// Datei ohne Team-Zuordnung = allgemein verfuegbar
if ($fileTeamIds->isEmpty()) {
return;
}
// Mindestens ein Team muss uebereinstimmen
if ($fileTeamIds->intersect($userTeamIds)->isEmpty()) {
abort(403);
}
}
}

View File

@@ -0,0 +1,686 @@
<?php
namespace App\Http\Controllers;
use App\Enums\UserRole;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class InstallerController extends Controller
{
/**
* Check if app is already installed.
*/
public static function isInstalled(): bool
{
return file_exists(storage_path('installed'));
}
// ─── Step 1: System Requirements ───────────────────────
public function requirements()
{
if (self::isInstalled()) {
return redirect()->route('login');
}
$checks = $this->runRequirementChecks();
return view('installer.steps.requirements', [
'currentStep' => 1,
'checks' => $checks,
'allPassed' => collect($checks)->where('required', true)->every(fn ($c) => $c['passed']),
]);
}
// ─── Step 2: Database ──────────────────────────────────
public function database()
{
if (self::isInstalled()) {
return redirect()->route('login');
}
return view('installer.steps.database', [
'currentStep' => 2,
'dbDriver' => old('db_driver', session('installer.db_driver', 'sqlite')),
]);
}
public function storeDatabase(Request $request)
{
if (self::isInstalled()) {
return redirect()->route('login');
}
$driver = $request->input('db_driver', 'sqlite');
if ($driver === 'mysql') {
$request->validate([
'db_host' => 'required|string',
'db_port' => 'required|integer|min:1|max:65535',
'db_database' => 'required|string',
'db_username' => 'required|string',
'db_password' => 'nullable|string',
]);
// Test MySQL connection before writing config
$testResult = $this->testMysqlConnection(
$request->input('db_host'),
(int) $request->input('db_port'),
$request->input('db_database'),
$request->input('db_username'),
$request->input('db_password', ''),
);
if ($testResult !== true) {
Log::error('Installer: DB connection failed', ['error' => $testResult]);
return back()->withInput()
->with('error', 'Datenbankverbindung fehlgeschlagen. Bitte Zugangsdaten pruefen.');
}
}
// Write DB config to .env
$this->updateEnvValues($this->buildDbEnvValues($driver, $request));
// For SQLite: ensure database file exists with secure permissions
if ($driver === 'sqlite') {
$dbPath = database_path('database.sqlite');
if (! file_exists($dbPath)) {
touch($dbPath);
}
chmod($dbPath, 0640);
}
// Clear config cache so new .env values take effect
Artisan::call('config:clear');
// Set the runtime DB config for this request (since .env was just written)
if ($driver === 'sqlite') {
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => database_path('database.sqlite'),
]);
} else {
config([
'database.default' => 'mysql',
'database.connections.mysql.host' => $request->input('db_host', '127.0.0.1'),
'database.connections.mysql.port' => $request->input('db_port', '3306'),
'database.connections.mysql.database' => $request->input('db_database'),
'database.connections.mysql.username' => $request->input('db_username'),
'database.connections.mysql.password' => $request->input('db_password', ''),
]);
}
// Run migrations
try {
Artisan::call('migrate', ['--force' => true]);
} catch (\Exception $e) {
Log::error('Installer: Migration failed', ['error' => $e->getMessage()]);
return back()->withInput()
->with('error', 'Migration fehlgeschlagen. Details im Laravel-Log.');
}
// Generate APP_KEY now (modifies .env — must happen before finalize)
if (empty(config('app.key')) || config('app.key') === 'base64:') {
Artisan::call('key:generate', ['--force' => true]);
}
// Store state in session
session(['installer.db_driver' => $driver]);
session(['installer.db_configured' => true]);
return redirect()->route('install.app');
}
// ─── Step 3: App Configuration ─────────────────────────
public function app()
{
if (self::isInstalled()) {
return redirect()->route('login');
}
if (! session('installer.db_configured')) {
return redirect()->route('install.database')
->with('error', 'Bitte zuerst die Datenbank konfigurieren.');
}
return view('installer.steps.app', [
'currentStep' => 3,
]);
}
public function storeApp(Request $request)
{
if (self::isInstalled()) {
return redirect()->route('login');
}
$request->validate([
'app_name' => 'required|string|max:100',
'app_slogan' => 'nullable|string|max:255',
'app_url' => 'required|url',
'admin_name' => 'required|string|max:255',
'admin_email' => 'required|email|max:255',
'admin_password' => ['required', 'string', \Illuminate\Validation\Rules\Password::min(8)->letters()->numbers(), 'confirmed'],
]);
// Write APP_NAME + APP_URL to .env now (triggers dev-server restart —
// safe here because we redirect immediately after)
$appName = $request->input('app_name');
$this->updateEnvValues([
'APP_NAME' => '"' . str_replace('"', '\\"', $appName) . '"',
'APP_URL' => $request->input('app_url'),
]);
session([
'installer.app_name' => $appName,
'installer.app_slogan' => $request->input('app_slogan'),
'installer.app_url' => $request->input('app_url'),
'installer.admin_name' => $request->input('admin_name'),
'installer.admin_email' => $request->input('admin_email'),
// Passwort sofort hashen (nicht Klartext in Session speichern).
// Der 'hashed' Cast im User-Model erkennt via Hash::isHashed()
// dass der Wert bereits gehasht ist und hasht NICHT doppelt.
'installer.admin_password_hash' => Hash::make($request->input('admin_password')),
'installer.app_configured' => true,
]);
return redirect()->route('install.mail');
}
// ─── Step 4: E-Mail Configuration ───────────────────────
public function mail()
{
if (self::isInstalled()) {
return redirect()->route('login');
}
if (! session('installer.app_configured')) {
return redirect()->route('install.app')
->with('error', 'Bitte zuerst die App-Einstellungen konfigurieren.');
}
$defaults = $this->getDefaultPasswordResetTexts();
return view('installer.steps.mail', [
'currentStep' => 4,
'defaultPwResetDe' => $defaults['de'],
]);
}
public function storeMail(Request $request)
{
if (self::isInstalled()) {
return redirect()->route('login');
}
$mailMode = $request->input('mail_mode', 'log');
if ($mailMode === 'smtp') {
$request->validate([
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_username' => 'required|string|max:255',
'mail_password' => 'required|string|max:255',
'mail_from_address' => 'required|email|max:255',
'mail_from_name' => 'nullable|string|max:255',
'mail_encryption' => 'required|in:tls,ssl,none',
]);
session([
'installer.mail_mode' => 'smtp',
'installer.mail_host' => $request->input('mail_host'),
'installer.mail_port' => $request->input('mail_port'),
'installer.mail_username' => $request->input('mail_username'),
'installer.mail_password' => $request->input('mail_password'),
'installer.mail_from_address' => $request->input('mail_from_address'),
'installer.mail_from_name' => $request->input('mail_from_name', ''),
'installer.mail_encryption' => $request->input('mail_encryption'),
]);
} else {
session(['installer.mail_mode' => 'log']);
}
session([
'installer.password_reset_email_de' => $request->input('password_reset_email_de', ''),
'installer.mail_configured' => true,
]);
return redirect()->route('install.finalize');
}
public function testMail(Request $request): \Illuminate\Http\JsonResponse
{
if (self::isInstalled()) {
return response()->json(['success' => false, 'message' => 'Bereits installiert.']);
}
$request->validate([
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_username' => 'required|string|max:255',
'mail_password' => 'required|string|max:255',
'mail_encryption' => 'required|in:tls,ssl,none',
]);
try {
$encryption = $request->input('mail_encryption');
$tls = ($encryption !== 'none');
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$request->input('mail_host'),
(int) $request->input('mail_port'),
$tls,
);
$transport->setUsername($request->input('mail_username'));
$transport->setPassword($request->input('mail_password'));
$transport->start();
$transport->stop();
return response()->json(['success' => true, 'message' => 'SMTP-Verbindung erfolgreich!']);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
// ─── Step 5: Finalize ──────────────────────────────────
public function finalize()
{
if (self::isInstalled()) {
return redirect()->route('login');
}
if (! session('installer.mail_configured')) {
return redirect()->route('install.mail')
->with('error', 'Bitte zuerst die E-Mail-Einstellungen konfigurieren.');
}
return view('installer.steps.finalize', [
'currentStep' => 5,
'appName' => session('installer.app_name'),
'appSlogan' => session('installer.app_slogan'),
'adminEmail' => session('installer.admin_email'),
'adminName' => session('installer.admin_name'),
'dbDriver' => session('installer.db_driver', 'sqlite'),
'installed' => false,
]);
}
public function storeFinalize(Request $request)
{
if (self::isInstalled()) {
return redirect()->route('login');
}
$installDemo = $request->boolean('install_demo');
// Pruefen ob alle Session-Daten vorhanden sind
$requiredSessionKeys = [
'installer.admin_email', 'installer.admin_name',
'installer.admin_password_hash', 'installer.app_name',
];
foreach ($requiredSessionKeys as $key) {
if (empty(session($key))) {
return back()->with('error', "Session-Daten verloren ('{$key}' fehlt). Bitte die Installation erneut ab Schritt 2 durchfuehren.");
}
}
// Datenbankverbindung sicherstellen (wurde in Schritt 2 konfiguriert via .env)
try {
\Illuminate\Support\Facades\DB::connection()->getPdo();
} catch (\Exception $e) {
return back()->with('error', 'Datenbankverbindung fehlgeschlagen: ' . $e->getMessage());
}
try {
$appName = session('installer.app_name');
// 1. Create admin user (guaranteed ID 1 on fresh DB)
$admin = User::updateOrCreate(
['email' => session('installer.admin_email')],
[
'name' => session('installer.admin_name'),
'password' => session('installer.admin_password_hash'),
]
);
$admin->is_active = true;
$admin->role = UserRole::Admin;
$admin->save();
// 2. Run required seeders (Settings + FileCategories)
Artisan::call('db:seed', [
'--class' => 'Database\\Seeders\\SettingsSeeder',
'--force' => true,
]);
Artisan::call('db:seed', [
'--class' => 'Database\\Seeders\\FileCategorySeeder',
'--force' => true,
]);
// 3. Override settings with installer values
Setting::set('app_name', $appName);
$slogan = session('installer.app_slogan');
if ($slogan) {
Setting::set('app_slogan', '<p><em>' . e($slogan) . '</em></p>');
}
// 4. Mail-Konfiguration in .env schreiben
$mailMode = session('installer.mail_mode', 'log');
if ($mailMode === 'smtp') {
$mailEncryption = session('installer.mail_encryption', 'tls');
$this->updateEnvValues([
'MAIL_MAILER' => 'smtp',
'MAIL_HOST' => session('installer.mail_host'),
'MAIL_PORT' => session('installer.mail_port'),
'MAIL_USERNAME' => session('installer.mail_username'),
'MAIL_PASSWORD' => session('installer.mail_password'),
'MAIL_FROM_ADDRESS' => session('installer.mail_from_address'),
'MAIL_FROM_NAME' => session('installer.mail_from_name', $appName),
'MAIL_SCHEME' => $mailEncryption === 'none' ? '' : $mailEncryption,
]);
} else {
$this->updateEnvValues([
'MAIL_MAILER' => 'log',
]);
}
// 5. Passwort-Reset E-Mail-Texte setzen
$customDe = session('installer.password_reset_email_de', '');
$defaults = $this->getDefaultPasswordResetTexts();
// DE: Benutzer-Text aus Installer oder Default
$deText = (strip_tags($customDe) !== '') ? $customDe : $defaults['de'];
Setting::set('password_reset_email_de', $deText);
// Andere Sprachen: Default-Texte setzen
foreach (['en', 'pl', 'ru', 'ar', 'tr'] as $locale) {
Setting::set('password_reset_email_' . $locale, $defaults[$locale]);
}
// 6. Optionally run DemoDataSeeder
if ($installDemo) {
Artisan::call('db:seed', [
'--class' => 'Database\\Seeders\\DemoDataSeeder',
'--force' => true,
]);
}
// 7. Create storage symlink
try {
Artisan::call('storage:link');
} catch (\Exception $e) {
// May already exist
}
// 8. Clear all caches
Artisan::call('config:clear');
Artisan::call('cache:clear');
Artisan::call('view:clear');
Artisan::call('route:clear');
try {
Setting::clearCache();
} catch (\Exception $e) {
// Cache may already be cleared
}
// 9. Mark as installed
$installedPath = storage_path('installed');
file_put_contents($installedPath, json_encode([
'installed_at' => now()->toIso8601String(),
'version' => config('app.version'),
'php_version' => PHP_VERSION,
'db_driver' => session('installer.db_driver', 'sqlite'),
], JSON_PRETTY_PRINT));
chmod($installedPath, 0600);
// 9b. Opt-in registration with support backend
if ($request->boolean('register_installation')) {
try {
$supportService = app(\App\Services\SupportApiService::class);
$supportService->register([
'app_name' => $appName,
'app_url' => session('installer.app_url'),
'app_version' => config('app.version'),
'php_version' => PHP_VERSION,
'db_driver' => session('installer.db_driver', 'sqlite'),
'installed_at' => now()->toIso8601String(),
]);
} catch (\Exception $e) {
Log::warning('Installation registration failed: ' . $e->getMessage());
}
}
// 9c. Store license key if provided
$licenseKey = $request->input('license_key');
if ($licenseKey) {
Setting::set('license_key', trim($licenseKey));
}
// 10. Store completion info in session, then clean up installer data
$completionData = [
'installed' => true,
'install_demo' => $installDemo,
'admin_email' => session('installer.admin_email'),
'admin_name' => session('installer.admin_name'),
];
session()->forget([
'installer.db_driver', 'installer.db_configured',
'installer.app_name', 'installer.app_slogan',
'installer.app_url', 'installer.admin_name',
'installer.admin_email', 'installer.admin_password_hash',
'installer.app_configured',
'installer.mail_mode', 'installer.mail_host',
'installer.mail_port', 'installer.mail_username',
'installer.mail_password', 'installer.mail_from_address',
'installer.mail_from_name', 'installer.mail_encryption',
'installer.password_reset_email_de', 'installer.mail_configured',
]);
session(['installer.completed' => $completionData]);
return redirect()->route('install.complete');
} catch (\Exception $e) {
Log::error('Installer: Installation failed', [
'error' => $e->getMessage(),
'file' => $e->getFile() . ':' . $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
// Waehrend der Installation ist kein Laravel-Log per FTP leicht zugaenglich.
// Daher zeigen wir die Fehlermeldung direkt an.
$errorDetail = $e->getMessage();
$errorFile = basename($e->getFile()) . ':' . $e->getLine();
return back()->with('error', "Installation fehlgeschlagen: {$errorDetail} (in {$errorFile})");
}
}
// ─── Completion Page ───────────────────────────────────
public function complete()
{
$data = session('installer.completed');
if (! $data) {
return redirect('/login');
}
// Clear the completion data so this page can't be revisited
session()->forget('installer.completed');
return view('installer.steps.finalize', [
'currentStep' => 5,
'installed' => true,
'installDemo' => $data['install_demo'] ?? false,
'adminEmail' => $data['admin_email'] ?? '',
'adminName' => $data['admin_name'] ?? '',
'appName' => null,
'dbDriver' => null,
]);
}
// ─── Private Helpers ───────────────────────────────────
private function runRequirementChecks(): array
{
$checks = [];
// PHP version
$checks[] = [
'name' => 'PHP Version >= 8.2',
'current' => PHP_VERSION,
'passed' => version_compare(PHP_VERSION, '8.2.0', '>='),
'required' => true,
];
// Required PHP extensions
foreach (['pdo', 'pdo_sqlite', 'mbstring', 'openssl', 'tokenizer', 'xml', 'ctype', 'fileinfo', 'dom'] as $ext) {
$checks[] = [
'name' => "PHP Extension: {$ext}",
'current' => extension_loaded($ext) ? 'Geladen' : 'Fehlt',
'passed' => extension_loaded($ext),
'required' => true,
];
}
// Optional: pdo_mysql
$checks[] = [
'name' => 'PHP Extension: pdo_mysql (nur für MySQL)',
'current' => extension_loaded('pdo_mysql') ? 'Geladen' : 'Fehlt',
'passed' => extension_loaded('pdo_mysql'),
'required' => false,
];
// Directory permissions
$dirs = [
'storage/' => storage_path(),
'storage/app/' => storage_path('app'),
'storage/framework/cache/' => storage_path('framework/cache'),
'storage/framework/sessions/' => storage_path('framework/sessions'),
'storage/framework/views/' => storage_path('framework/views'),
'storage/logs/' => storage_path('logs'),
'bootstrap/cache/' => base_path('bootstrap/cache'),
'database/' => database_path(),
];
foreach ($dirs as $label => $path) {
$checks[] = [
'name' => "Schreibberechtigung: {$label}",
'current' => is_writable($path) ? 'Schreibbar' : 'Nicht schreibbar',
'passed' => is_writable($path),
'required' => true,
];
}
// .env file
$checks[] = [
'name' => '.env Datei',
'current' => file_exists(base_path('.env')) ? 'Vorhanden' : 'Fehlt',
'passed' => file_exists(base_path('.env')),
'required' => true,
];
return $checks;
}
private function testMysqlConnection(string $host, int $port, string $database, string $username, string $password): true|string
{
try {
new \PDO(
"mysql:host={$host};port={$port};dbname={$database}",
$username,
$password,
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_TIMEOUT => 5]
);
return true;
} catch (\PDOException $e) {
return $e->getMessage();
}
}
private function buildDbEnvValues(string $driver, Request $request): array
{
if ($driver === 'sqlite') {
return [
'DB_CONNECTION' => 'sqlite',
'DB_HOST' => '',
'DB_PORT' => '',
'DB_DATABASE' => '',
'DB_USERNAME' => '',
'DB_PASSWORD' => '',
];
}
$password = $request->input('db_password', '');
return [
'DB_CONNECTION' => 'mysql',
'DB_HOST' => $request->input('db_host', '127.0.0.1'),
'DB_PORT' => $request->input('db_port', '3306'),
'DB_DATABASE' => $request->input('db_database'),
'DB_USERNAME' => $request->input('db_username'),
'DB_PASSWORD' => $password !== '' ? '"' . str_replace('"', '\\"', $password) . '"' : '',
];
}
private function getDefaultPasswordResetTexts(): array
{
return [
'de' => '<p>Hallo {name},</p><p>du hast eine Passwort-Zuruecksetzung fuer dein Konto bei {app_name} angefordert. Klicke auf den Button unten, um ein neues Passwort zu vergeben.</p><p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>',
'en' => '<p>Hello {name},</p><p>You requested a password reset for your account at {app_name}. Click the button below to set a new password.</p><p>If you did not request this, you can safely ignore this email.</p>',
'pl' => '<p>Witaj {name},</p><p>Otrzymalismy prosbe o zresetowanie hasla do Twojego konta w {app_name}. Kliknij przycisk ponizej, aby ustawic nowe haslo.</p><p>Jesli nie prosiles o zmiane hasla, zignoruj te wiadomosc.</p>',
'ru' => '<p>Здравствуйте {name},</p><p>Вы запросили сброс пароля для вашей учетной записи в {app_name}. Нажмите кнопку ниже, чтобы установить новый пароль.</p><p>Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо.</p>',
'ar' => '<p>مرحباً {name}،</p><p>لقد تلقينا طلباً لإعادة تعيين كلمة المرور لحسابك في {app_name}. انقر على الزر أدناه لتعيين كلمة مرور جديدة.</p><p>إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني.</p>',
'tr' => '<p>Merhaba {name},</p><p>{app_name} hesabiniz icin sifre sifirlama talebinde bulundunuz. Yeni bir sifre belirlemek icin asagidaki butona tiklayin.</p><p>Bu talebi siz yapmadiysan, bu e-postayi goerurmezden gelebilirsiniz.</p>',
];
}
private function updateEnvValues(array $values): void
{
$envPath = base_path('.env');
$envContent = file_get_contents($envPath);
foreach ($values as $key => $value) {
// Empty values: comment out the line
if ($value === '' || $value === null) {
$pattern = "/^{$key}=.*/m";
if (preg_match($pattern, $envContent)) {
$envContent = preg_replace($pattern, "# {$key}=", $envContent);
}
continue;
}
// Newline-Injection verhindern und Werte quoten (T07)
$value = str_replace(["\n", "\r", "\0"], '', $value);
if (!preg_match('/^".*"$/', $value)) {
$value = '"' . str_replace('"', '\\"', $value) . '"';
}
$replacement = "{$key}={$value}";
$pattern = "/^{$key}=.*/m";
if (preg_match($pattern, $envContent)) {
$envContent = preg_replace($pattern, $replacement, $envContent);
} else {
// Also check for commented-out version
$commentPattern = "/^#\s*{$key}=.*/m";
if (preg_match($commentPattern, $envContent)) {
$envContent = preg_replace($commentPattern, $replacement, $envContent);
} else {
$envContent .= "\n{$replacement}";
}
}
}
file_put_contents($envPath, $envContent);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\EventParticipant;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ParticipantController extends Controller
{
public function update(Request $request, Event $event): RedirectResponse
{
$user = auth()->user();
if ($event->status === EventStatus::Cancelled) {
abort(403);
}
if (!$user->canAccessAdminPanel() && $event->status === EventStatus::Draft) {
abort(403);
}
// Team-Zugriffspruefung: User muss Zugang zum Event-Team haben
if (!$user->canAccessAdminPanel()) {
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
abort(403);
}
}
if ($event->type === EventType::Meeting) {
return $this->updateMeetingParticipant($request, $event);
}
return $this->updatePlayerParticipant($request, $event);
}
private function updatePlayerParticipant(Request $request, Event $event): RedirectResponse
{
$user = auth()->user();
$request->validate([
'player_id' => 'required|integer',
'status' => 'required|in:yes,no,unknown',
]);
$participant = EventParticipant::where('event_id', $event->id)
->where('player_id', $request->player_id)
->firstOrFail();
// Policy-Check: nur eigene Kinder oder Admin
if (!$user->canAccessAdminPanel()) {
$isParent = DB::table('parent_player')
->where('parent_id', $user->id)
->where('player_id', $request->player_id)
->exists();
if (!$isParent) {
abort(403);
}
}
$oldStatus = $participant->status->value;
$participant->status = ParticipantStatus::from($request->status);
$participant->set_by_user_id = $user->id;
$participant->responded_at = now();
$participant->save();
ActivityLog::logWithChanges('participant_status_changed', __('admin.log_participant_changed', ['event' => $event->title, 'status' => $request->status]), 'Event', $event->id, ['status' => $oldStatus, 'player' => $participant->player?->full_name ?? ''], ['status' => $request->status]);
return redirect(route('events.show', $event) . '#participants');
}
private function updateMeetingParticipant(Request $request, Event $event): RedirectResponse
{
$user = auth()->user();
$request->validate([
'user_id' => 'required|integer',
'status' => 'required|in:yes,no,unknown',
]);
$participant = EventParticipant::where('event_id', $event->id)
->where('user_id', $request->user_id)
->firstOrFail();
// Policy-Check: nur eigener Eintrag oder Admin
if (!$user->canAccessAdminPanel() && (int) $participant->user_id !== $user->id) {
abort(403);
}
$oldStatus = $participant->status->value;
$participant->status = ParticipantStatus::from($request->status);
$participant->set_by_user_id = $user->id;
$participant->responded_at = now();
$participant->save();
ActivityLog::logWithChanges('participant_status_changed', __('admin.log_participant_changed', ['event' => $event->title, 'status' => $request->status]), 'Event', $event->id, ['status' => $oldStatus, 'player' => $participant->user?->name ?? ''], ['status' => $request->status]);
return redirect(route('events.show', $event) . '#participants');
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers;
use App\Enums\UserRole;
use App\Models\ActivityLog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ProfileController extends Controller
{
public function edit(): View
{
$user = auth()->user();
$user->load('children.team');
return view('profile.edit', compact('user'));
}
public function update(Request $request): RedirectResponse
{
$supported = \App\Http\Middleware\SetLocaleMiddleware::supportedLocales();
$request->validate([
'name' => 'required|string|max:255',
'phone' => ['nullable', 'string', 'max:30'],
'locale' => ['nullable', 'string', 'in:' . implode(',', $supported)],
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
]);
$user = auth()->user();
// Handle profile picture upload
if ($request->hasFile('profile_picture')) {
// Delete old 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);
$user->profile_picture = $storedName;
}
$user->update([
'name' => $request->name,
'phone' => $request->input('phone'),
'locale' => $request->input('locale', 'de'),
'profile_picture' => $user->profile_picture,
]);
return back()->with('success', __('profile.updated'));
}
public function removePicture(): RedirectResponse
{
$user = auth()->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 uploadDsgvoConsent(Request $request): RedirectResponse
{
$request->validate([
'dsgvo_consent_file' => ['required', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png,gif,webp'],
]);
$user = auth()->user();
// Alte Datei löschen falls vorhanden
if ($user->dsgvo_consent_file) {
Storage::disk('local')->delete($user->dsgvo_consent_file);
}
$file = $request->file('dsgvo_consent_file');
$storedName = 'dsgvo/' . Str::uuid() . '.' . $file->guessExtension();
Storage::disk('local')->putFileAs('', $file, $storedName);
// Bei Re-Upload: Bestätigung zurücksetzen
$user->dsgvo_consent_file = $storedName;
$user->dsgvo_accepted_at = null;
$user->dsgvo_accepted_by = null;
$user->save();
ActivityLog::log(
'dsgvo_consent_uploaded',
__('admin.log_dsgvo_consent_uploaded', ['name' => $user->name]),
'User',
$user->id
);
return back()->with('success', __('profile.dsgvo_uploaded'));
}
public function removeDsgvoConsent(): RedirectResponse
{
$user = auth()->user();
if ($user->dsgvo_consent_file) {
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_removed',
__('admin.log_dsgvo_consent_removed', ['name' => $user->name]),
'User',
$user->id
);
}
return back()->with('success', __('profile.dsgvo_removed'));
}
public function downloadDsgvoConsent(): BinaryFileResponse
{
$user = auth()->user();
if (!$user->dsgvo_consent_file || !str_starts_with($user->dsgvo_consent_file, 'dsgvo/') || !Storage::disk('local')->exists($user->dsgvo_consent_file)) {
abort(404);
}
$mimeType = Storage::disk('local')->mimeType($user->dsgvo_consent_file);
return response()->file(
Storage::disk('local')->path($user->dsgvo_consent_file),
['Content-Type' => $mimeType]
);
}
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = auth()->user();
// Admin (ID 1) kann nicht selbst löschen
if ($user->id === 1) {
return back()->with('error', __('profile.cannot_delete_admin'));
}
// Staff (Admin/Trainer) können sich nicht über die Profilseite löschen
if ($user->isStaff()) {
return back()->with('error', __('profile.cannot_delete_staff'));
}
// Verwaiste Kinder ermitteln und deaktivieren
$orphanedChildren = $user->getOrphanedChildren();
$orphanedChildNames = [];
foreach ($orphanedChildren as $child) {
$child->delete();
$orphanedChildNames[] = $child->full_name;
ActivityLog::log(
'child_auto_deactivated',
__('admin.log_child_auto_deactivated', [
'child' => $child->full_name,
'parent' => $user->name,
]),
'Player',
$child->id,
['parent_user_id' => $user->id, 'reason' => 'sole_parent_self_deleted']
);
}
// Selbstlöschung loggen
ActivityLog::logWithChanges(
'account_self_deleted',
__('admin.log_account_self_deleted', ['name' => $user->name]),
'User',
$user->id,
[
'name' => $user->name,
'email' => $user->email,
'role' => $user->role->value,
'orphaned_children' => $orphanedChildNames,
],
null
);
// Logout + Session invalidieren
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// User soft-deleten
$user->delete();
return redirect()->route('login')->with('success', __('profile.account_deleted'));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\EventTimekeeper;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TimekeeperController extends Controller
{
public function update(Request $request, Event $event): RedirectResponse
{
$user = auth()->user();
if ($event->status === EventStatus::Cancelled) {
abort(403);
}
if (!$event->type->hasTimekeepers()) {
abort(403);
}
if (!$user->canAccessAdminPanel()) {
if ($event->status === EventStatus::Draft) {
abort(403);
}
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
abort(403);
}
}
$request->validate([
'status' => 'required|in:yes,no,unknown',
]);
$existing = EventTimekeeper::where('event_id', $event->id)->where('user_id', auth()->id())->first();
$oldStatus = $existing?->status?->value ?? 'unknown';
$timekeeper = EventTimekeeper::where('event_id', $event->id)->where('user_id', auth()->id())->first();
if (!$timekeeper) {
$timekeeper = new EventTimekeeper(['event_id' => $event->id]);
$timekeeper->user_id = auth()->id();
}
$timekeeper->status = CateringStatus::from($request->status);
$timekeeper->save();
ActivityLog::logWithChanges('status_changed', __('admin.log_timekeeper_changed', ['event' => $event->title, 'status' => $request->status]), 'Event', $event->id, ['status' => $oldStatus, 'user_id' => auth()->id(), 'source' => 'timekeeper'], ['status' => $request->status, 'user_id' => auth()->id(), 'source' => 'timekeeper']);
return redirect(route('events.show', $event) . '#timekeeper');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class ActiveUserMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user() && (!$request->user()->is_active || $request->user()->trashed())) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')
->with('error', __('auth_ui.account_deactivated'));
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->canAccessAdminPanel()) {
abort(403, 'Zugriff verweigert.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminOnlyMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isAdmin()) {
abort(403, 'Zugriff verweigert.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class DsgvoConsentMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user && $user->isDsgvoRestricted()) {
return back()->with('error', __('ui.dsgvo_restricted'));
}
return $next($request);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class InstallerMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Auto-create .env from .env.example if missing (first-time deployment)
$envPath = base_path('.env');
if (! file_exists($envPath) && file_exists(base_path('.env.example'))) {
copy(base_path('.env.example'), $envPath);
}
$isInstalled = file_exists(storage_path('installed'));
$isInstallerRoute = $request->is('install') || $request->is('install/*');
// Not installed + not on installer routes → redirect to installer
if (! $isInstalled && ! $isInstallerRoute) {
// Allow static assets and health check through
if ($request->is('favicon.ico', 'images/*', 'up', 'manifest.json', 'sw.js', 'storage/*')) {
return $next($request);
}
return redirect('/install');
}
// Already installed + on installer routes → redirect to login
if ($isInstalled && $isInstallerRoute) {
return redirect('/login');
}
// Setup-Token-Schutz: Installer nur mit gültigem Token erreichbar
if (! $isInstalled && $isInstallerRoute) {
$tokenFile = storage_path('setup-token');
// Token-Datei beim ersten Zugriff generieren
if (! file_exists($tokenFile)) {
$token = bin2hex(random_bytes(16));
file_put_contents($tokenFile, $token);
chmod($tokenFile, 0600);
// Nur Token-Hash loggen (Klartext in Datei storage/setup-token)
logger()->warning("Installer Setup-Token generiert (SHA256: " . hash('sha256', $token) . ")");
logger()->warning("Token befindet sich in: storage/setup-token");
}
$expectedToken = trim(file_get_contents($tokenFile));
$providedToken = $request->query('token');
// Session ist ggf. noch nicht gestartet (Middleware laeuft vor StartSession)
$sessionTokenHash = $request->hasSession() ? $request->session()->get('setup_token_hash') : null;
if ($providedToken && hash_equals($expectedToken, $providedToken)) {
// Token-Hash in Session speichern — kein Klartext in Session (V11)
if ($request->hasSession()) {
$request->session()->put('setup_token_hash', hash('sha256', $expectedToken));
}
} elseif ($sessionTokenHash && hash_equals(hash('sha256', $expectedToken), $sessionTokenHash)) {
// Gültiges Token via Session-Hash
} elseif (! $request->is('install')) {
// Nur die Startseite ohne Token erlauben (zeigt Token-Eingabe)
abort(403, 'Ungültiges Setup-Token.');
}
}
// Force file-based session/cache during installation (DB may not exist yet).
// Fixed cookie name prevents session loss when APP_NAME changes in .env mid-install.
if (! $isInstalled && $isInstallerRoute) {
config([
'session.driver' => 'file',
'cache.default' => 'file',
'session.cookie' => 'handball_installer_session',
]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeadersMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Server-Fingerprinting verhindern
header_remove('X-Powered-By');
$response->headers->remove('X-Powered-By');
$response->headers->remove('Server');
// Content Security Policy — erlaubt CDN-Quellen für Tailwind, Alpine, Quill, Leaflet
// 'unsafe-inline' benötigt von: Tailwind CDN (inline Styles), Alpine.js (Event-Handler)
// 'unsafe-eval' benötigt von: Tailwind CDN (JIT nutzt new Function())
// Entfernung nur möglich durch Wechsel auf self-hosted/kompilierte Assets
$cspDirectives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com https://cdn.quilljs.com",
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com https://cdn.quilljs.com",
"img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com",
"font-src 'self' https://cdn.jsdelivr.net https://cdn.quilljs.com",
"connect-src 'self' https://photon.komoot.io",
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
];
// upgrade-insecure-requests nur bei HTTPS — bricht sonst lokale HTTP-Entwicklung (Herd/artisan serve)
if ($request->secure()) {
$cspDirectives[] = "upgrade-insecure-requests";
}
$csp = implode('; ', $cspDirectives);
$response->headers->set('Content-Security-Policy', $csp);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self), payment=(), usb=(), bluetooth=(), autoplay=(), magnetometer=(), gyroscope=(), accelerometer=()');
// Cross-Origin Isolation Headers
$response->headers->set('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
// HSTS — HTTPS fuer 1 Jahr erzwingen (nur bei HTTPS-Requests aktiv)
if ($request->secure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use Carbon\Carbon;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SetLocaleMiddleware
{
private const SUPPORTED_LOCALES = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
public function handle(Request $request, Closure $next): Response
{
$locale = $this->resolveLocale($request);
app()->setLocale($locale);
Carbon::setLocale($locale);
return $next($request);
}
private function resolveLocale(Request $request): string
{
// 1. Eingeloggter User → DB-Präferenz
if ($request->user() && in_array($request->user()->locale, self::SUPPORTED_LOCALES)) {
return $request->user()->locale;
}
// 2. Session (für Gastseiten)
if (session()->has('locale') && in_array(session('locale'), self::SUPPORTED_LOCALES)) {
return session('locale');
}
// 3. Fallback
return 'de';
}
public static function supportedLocales(): array
{
return self::SUPPORTED_LOCALES;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class StaffMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isStaff()) {
abort(403, 'Zugriff verweigert.');
}
return $next($request);
}
}