Feature-Toggles, Administration, wiederkehrende Events und Event-Serien
- Administration & Rollenmanagement: Neuer Admin-Bereich mit Feature-Toggles und Sichtbarkeitseinstellungen pro Rolle (11 Toggles, 24 Visibility-Settings) - AdministrationController mit eigenem Settings-Tab, aus SettingsController extrahiert - Feature-Toggle-Guards in Controllers (Invitation, File, ListGenerator, Comment) und Views (events/show, events/edit, events/create) - Setting::isFeatureEnabled() und isFeatureVisibleFor() Hilfsmethoden - Wiederkehrende Trainings: Täglich/Wöchentlich/2-Wöchentlich mit Ende per Datum oder Anzahl (max. 52), Vorschau im Formular - Event-Serien: Verknüpfung über event_series_id (UUID), Modal-Dialog beim Speichern und Löschen mit Optionen "nur dieses" / "alle folgenden" - Löschen-Button direkt in der Event-Bearbeitung mit Serien-Dialog - DemoDataSeeder: 4 Trainings als Serie mit gemeinsamer event_series_id - Übersetzungen in allen 6 Sprachen (de, en, pl, ru, ar, tr) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
345
app/Http/Controllers/Admin/AdministrationController.php
Normal file
345
app/Http/Controllers/Admin/AdministrationController.php
Normal file
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
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\View\View;
|
||||
|
||||
class AdministrationController extends Controller
|
||||
{
|
||||
public function __construct(private HtmlSanitizerService $sanitizer) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$allSettings = Setting::all()->keyBy('key');
|
||||
|
||||
// Feature-Toggle-Settings
|
||||
$featureSettings = $allSettings->filter(fn ($s) => str_starts_with($s->key, 'feature_'));
|
||||
|
||||
// Visibility-Settings
|
||||
$visibilitySettings = $allSettings->filter(fn ($s) => str_starts_with($s->key, 'visibility_'));
|
||||
|
||||
// Mail-Konfiguration
|
||||
$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'),
|
||||
];
|
||||
|
||||
// Lizenz & Support
|
||||
$supportService = app(SupportApiService::class);
|
||||
$isRegistered = $supportService->isRegistered();
|
||||
$installationId = $isRegistered ? ($supportService->readInstalled()['installation_id'] ?? null) : null;
|
||||
$updateInfo = Cache::get('support.update_check');
|
||||
|
||||
// License-Key
|
||||
$licenseKey = $allSettings['license_key']->value ?? '';
|
||||
|
||||
// Aktivitätslog (letzte 100 Einträge)
|
||||
$recentLogs = ActivityLog::with('user')->latest('created_at')->limit(100)->get();
|
||||
|
||||
return view('admin.administration.index', compact(
|
||||
'featureSettings', 'visibilitySettings', 'mailConfig',
|
||||
'isRegistered', 'installationId', 'updateInfo', 'licenseKey',
|
||||
'recentLogs'
|
||||
));
|
||||
}
|
||||
|
||||
public function updateFeatures(Request $request): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$inputSettings = $request->input('settings', []);
|
||||
|
||||
$oldValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
|
||||
|
||||
foreach ($inputSettings as $key => $value) {
|
||||
// Nur feature_ und visibility_ Keys akzeptieren
|
||||
if (!str_starts_with($key, 'feature_') && !str_starts_with($key, 'visibility_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$setting = Setting::where('key', $key)->first();
|
||||
if ($setting) {
|
||||
$setting->update(['value' => $value]);
|
||||
} else {
|
||||
$newSetting = new Setting([
|
||||
'label' => $key,
|
||||
'type' => 'number',
|
||||
'value' => $value,
|
||||
]);
|
||||
$newSetting->key = $key;
|
||||
$newSetting->save();
|
||||
}
|
||||
}
|
||||
|
||||
Setting::clearCache();
|
||||
|
||||
$newValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
|
||||
ActivityLog::logWithChanges('updated', __('admin.features_saved'), 'Setting', null, $oldValues, $newValues);
|
||||
|
||||
return back()->with('success', __('admin.features_saved'));
|
||||
}
|
||||
|
||||
public function updateLicense(Request $request): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'license_key' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$oldValue = Setting::get('license_key');
|
||||
$newValue = strip_tags($request->input('license_key', ''));
|
||||
|
||||
Setting::set('license_key', $newValue);
|
||||
|
||||
if ($newValue && $newValue !== $oldValue) {
|
||||
$supportService = app(SupportApiService::class);
|
||||
$result = $supportService->validateLicense($newValue);
|
||||
if ($result && !($result['valid'] ?? false)) {
|
||||
session()->flash('warning', __('admin.license_invalid'));
|
||||
}
|
||||
}
|
||||
|
||||
ActivityLog::logWithChanges('updated', __('admin.log_settings_updated'), 'Setting', null,
|
||||
['license_key' => $oldValue], ['license_key' => $newValue]);
|
||||
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroyDemoData(Request $request): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
DB::table('activity_logs')->delete();
|
||||
DB::table('comments')->delete();
|
||||
DB::table('event_player_stats')->delete();
|
||||
DB::table('event_carpool_passengers')->delete();
|
||||
DB::table('event_carpools')->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();
|
||||
|
||||
$files = DB::table('files')->get();
|
||||
foreach ($files as $file) {
|
||||
Storage::disk('private')->delete($file->path);
|
||||
}
|
||||
DB::table('files')->delete();
|
||||
|
||||
$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.administration.index')
|
||||
->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'],
|
||||
]);
|
||||
|
||||
Storage::disk('private')->deleteDirectory('files');
|
||||
Storage::disk('public')->deleteDirectory('avatars');
|
||||
Storage::disk('public')->deleteDirectory('favicon');
|
||||
Storage::disk('public')->deleteDirectory('logos');
|
||||
Storage::disk('public')->deleteDirectory('dsgvo');
|
||||
|
||||
$driver = DB::getDriverName();
|
||||
if ($driver === 'sqlite') {
|
||||
DB::statement('PRAGMA foreign_keys = OFF;');
|
||||
} else {
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0;');
|
||||
}
|
||||
|
||||
$tables = [
|
||||
'activity_logs', 'comments', 'event_player_stats',
|
||||
'event_carpool_passengers', 'event_carpools', '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();
|
||||
}
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
DB::statement('PRAGMA foreign_keys = ON;');
|
||||
} else {
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1;');
|
||||
}
|
||||
|
||||
$installedFile = storage_path('installed');
|
||||
if (file_exists($installedFile)) {
|
||||
unlink($installedFile);
|
||||
}
|
||||
|
||||
Artisan::call('cache:clear');
|
||||
Artisan::call('config:clear');
|
||||
Artisan::call('view:clear');
|
||||
Artisan::call('route:clear');
|
||||
|
||||
auth()->logout();
|
||||
request()->session()->invalidate();
|
||||
request()->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('install.requirements');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,17 @@ namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function softDelete(Comment $comment): RedirectResponse
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('comments', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$comment->deleted_at = now();
|
||||
$comment->deleted_by = auth()->id();
|
||||
$comment->save();
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Models\Team;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\HtmlSanitizerService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -105,8 +106,14 @@ class EventController extends Controller
|
||||
|
||||
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]);
|
||||
|
||||
$recurringCount = $this->generateRecurringEvents($event, $request);
|
||||
|
||||
$message = $recurringCount > 0
|
||||
? __('admin.recurrence_created', ['count' => $recurringCount + 1])
|
||||
: __('admin.event_created');
|
||||
|
||||
return redirect()->route('admin.events.index')
|
||||
->with('success', __('admin.event_created'));
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function edit(Event $event): View
|
||||
@@ -172,8 +179,24 @@ class EventController extends Controller
|
||||
$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);
|
||||
|
||||
// Serien-Events: Alle folgenden aktualisieren
|
||||
$updatedFollowing = 0;
|
||||
if ($request->input('update_following') === '1' && $event->isPartOfSeries()) {
|
||||
$updatedFollowing = $this->updateFollowingSeriesEvents($event, $request);
|
||||
}
|
||||
|
||||
$recurringCount = $this->generateRecurringEvents($event, $request);
|
||||
|
||||
if ($recurringCount > 0) {
|
||||
$message = __('admin.recurrence_created', ['count' => $recurringCount + 1]);
|
||||
} elseif ($updatedFollowing > 0) {
|
||||
$message = __('admin.series_events_updated', ['count' => $updatedFollowing]);
|
||||
} else {
|
||||
$message = __('admin.event_updated');
|
||||
}
|
||||
|
||||
return redirect()->route('admin.events.index')
|
||||
->with('success', __('admin.event_updated'));
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function updateParticipant(Request $request, Event $event)
|
||||
@@ -199,16 +222,34 @@ class EventController extends Controller
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function destroy(Event $event): RedirectResponse
|
||||
public function destroy(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$deletedCount = 1;
|
||||
|
||||
// Serien-Events: Alle folgenden auch löschen
|
||||
if ($request->input('delete_following') === '1' && $event->isPartOfSeries()) {
|
||||
$followingEvents = $event->followingSeriesEvents()->get();
|
||||
foreach ($followingEvents as $futureEvent) {
|
||||
ActivityLog::logWithChanges('deleted', __('admin.log_event_deleted', ['title' => $futureEvent->title]), 'Event', $futureEvent->id, ['title' => $futureEvent->title, 'team' => $futureEvent->team->name ?? ''], null);
|
||||
$futureEvent->deleted_by = auth()->id();
|
||||
$futureEvent->save();
|
||||
$futureEvent->delete();
|
||||
$deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
$message = $deletedCount > 1
|
||||
? __('admin.series_events_deleted', ['count' => $deletedCount])
|
||||
: __('admin.event_deleted');
|
||||
|
||||
return redirect()->route('admin.events.index')
|
||||
->with('success', __('admin.event_deleted'));
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function restore(int $id): RedirectResponse
|
||||
@@ -333,6 +374,10 @@ class EventController extends Controller
|
||||
'opponent' => ['nullable', 'string', 'max:100'],
|
||||
'score_home' => ['nullable', 'integer', 'min:0', 'max:99'],
|
||||
'score_away' => ['nullable', 'integer', 'min:0', 'max:99'],
|
||||
'recurrence' => ['nullable', 'in:none,daily,weekly,biweekly'],
|
||||
'recurrence_end_type' => ['nullable', 'in:date,count'],
|
||||
'recurrence_end_date' => ['nullable', 'date'],
|
||||
'recurrence_count' => ['nullable', 'integer', 'min:1', 'max:52'],
|
||||
]);
|
||||
|
||||
// Datum und Uhrzeit zusammenführen
|
||||
@@ -340,6 +385,9 @@ class EventController extends Controller
|
||||
$validated['end_at'] = null;
|
||||
unset($validated['start_date'], $validated['start_time']);
|
||||
|
||||
// Recurrence-Felder aus validated entfernen (werden separat verarbeitet)
|
||||
unset($validated['recurrence'], $validated['recurrence_end_type'], $validated['recurrence_end_date'], $validated['recurrence_count']);
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
@@ -514,4 +562,108 @@ class EventController extends Controller
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function updateFollowingSeriesEvents(Event $event, Request $request): int
|
||||
{
|
||||
$followingEvents = $event->followingSeriesEvents()->get();
|
||||
if ($followingEvents->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$newTime = Carbon::parse($event->start_at);
|
||||
|
||||
foreach ($followingEvents as $futureEvent) {
|
||||
$futureEvent->title = $event->title;
|
||||
$futureEvent->location_name = $event->location_name;
|
||||
$futureEvent->address_text = $event->address_text;
|
||||
$futureEvent->location_lat = $event->location_lat;
|
||||
$futureEvent->location_lng = $event->location_lng;
|
||||
$futureEvent->description_html = $event->description_html;
|
||||
$futureEvent->min_players = $event->min_players;
|
||||
$futureEvent->min_catering = $event->min_catering;
|
||||
$futureEvent->min_timekeepers = $event->min_timekeepers;
|
||||
|
||||
// Uhrzeit anpassen (Datum behalten)
|
||||
$futureDate = Carbon::parse($futureEvent->start_at);
|
||||
$futureEvent->start_at = $futureDate->setTime($newTime->hour, $newTime->minute);
|
||||
|
||||
$futureEvent->updated_by = auth()->id();
|
||||
$futureEvent->save();
|
||||
|
||||
// Catering/Zeitnehmer-Zuweisungen neu synchen
|
||||
$this->syncAssignments($futureEvent, $request);
|
||||
}
|
||||
|
||||
return $followingEvents->count();
|
||||
}
|
||||
|
||||
private function generateRecurringEvents(Event $baseEvent, Request $request): int
|
||||
{
|
||||
$recurrence = $request->input('recurrence', 'none');
|
||||
if ($recurrence === 'none' || $baseEvent->type !== EventType::Training) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$interval = match ($recurrence) {
|
||||
'daily' => 1,
|
||||
'weekly' => 7,
|
||||
'biweekly' => 14,
|
||||
default => 0,
|
||||
};
|
||||
if ($interval === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Termine berechnen
|
||||
$dates = [];
|
||||
$startDate = Carbon::parse($baseEvent->start_at);
|
||||
$endType = $request->input('recurrence_end_type', 'count');
|
||||
|
||||
if ($endType === 'date') {
|
||||
$endDate = Carbon::parse($request->input('recurrence_end_date'));
|
||||
$current = $startDate->copy()->addDays($interval);
|
||||
while ($current->lte($endDate) && count($dates) < 52) {
|
||||
$dates[] = $current->copy();
|
||||
$current->addDays($interval);
|
||||
}
|
||||
} else {
|
||||
$count = min((int) $request->input('recurrence_count', 1), 52);
|
||||
for ($i = 1; $i <= $count; $i++) {
|
||||
$dates[] = $startDate->copy()->addDays($interval * $i);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($dates)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Series-ID zuweisen
|
||||
$seriesId = (string) Str::uuid();
|
||||
$baseEvent->event_series_id = $seriesId;
|
||||
$baseEvent->save();
|
||||
|
||||
// Basis-Dateien zum Verlinken
|
||||
$fileIds = $baseEvent->files()->pluck('files.id')->toArray();
|
||||
|
||||
foreach ($dates as $date) {
|
||||
$newEvent = $baseEvent->replicate(['id', 'created_at', 'updated_at', 'deleted_at', 'deleted_by']);
|
||||
$newEvent->start_at = $date;
|
||||
$newEvent->created_by = auth()->id();
|
||||
$newEvent->updated_by = auth()->id();
|
||||
$newEvent->save();
|
||||
|
||||
// Teilnehmer erstellen
|
||||
$this->createParticipantsForTeam($newEvent);
|
||||
|
||||
// Catering/Zeitnehmer-Zuweisungen kopieren
|
||||
$this->syncAssignments($newEvent, $request);
|
||||
|
||||
// Dateien verlinken (nur bestehende, keine neuen Uploads)
|
||||
if (!empty($fileIds)) {
|
||||
$newEvent->files()->attach($fileIds);
|
||||
}
|
||||
}
|
||||
|
||||
return count($dates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\File;
|
||||
use App\Models\FileCategory;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -16,6 +17,10 @@ class FileController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('files', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$categories = FileCategory::ordered()->withCount('files')->get();
|
||||
$activeCategory = $request->query('category');
|
||||
|
||||
@@ -32,12 +37,20 @@ class FileController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('files', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$categories = FileCategory::active()->ordered()->get();
|
||||
return view('admin.files.create', compact('categories'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('files', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'file' => ['required', 'file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
|
||||
'file_category_id' => ['required', 'exists:file_categories,id'],
|
||||
@@ -68,6 +81,10 @@ class FileController extends Controller
|
||||
|
||||
public function destroy(File $file): RedirectResponse
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('files', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Path-Traversal-Schutz (V15)
|
||||
if (str_contains($file->stored_name, '..') || str_contains($file->stored_name, '/')) {
|
||||
abort(403);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Invitation;
|
||||
use App\Models\Player;
|
||||
use App\Models\Setting;
|
||||
use App\Services\InvitationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -17,6 +18,10 @@ class InvitationController extends Controller
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('invitations', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$invitations = Invitation::with(['creator', 'players.team'])
|
||||
->latest('created_at')
|
||||
->paginate(20);
|
||||
@@ -26,6 +31,10 @@ class InvitationController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('invitations', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$players = Player::with('team')->active()->orderBy('last_name')->get();
|
||||
|
||||
return view('admin.invitations.create', compact('players'));
|
||||
@@ -33,6 +42,10 @@ class InvitationController extends Controller
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('invitations', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'expires_in_days' => ['required', 'integer', 'min:1', 'max:90'],
|
||||
@@ -52,6 +65,10 @@ class InvitationController extends Controller
|
||||
|
||||
public function destroy(Invitation $invitation): RedirectResponse
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('invitations', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($invitation->isAccepted()) {
|
||||
return back()->with('error', __('admin.invitation_already_used'));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\ActivityLog;
|
||||
use App\Models\File;
|
||||
use App\Models\FileCategory;
|
||||
use App\Models\Player;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
@@ -19,6 +20,10 @@ class ListGeneratorController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('list_generator', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$teams = Team::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('admin.list-generator.create', compact('teams'));
|
||||
@@ -26,6 +31,10 @@ class ListGeneratorController extends Controller
|
||||
|
||||
public function store(Request $request): View
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('list_generator', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'subtitle' => 'nullable|string|max:255',
|
||||
|
||||
@@ -8,12 +8,8 @@ use App\Models\FileCategory;
|
||||
use App\Models\Season;
|
||||
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;
|
||||
@@ -39,12 +35,10 @@ class SettingsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 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, 'feature_') &&
|
||||
!str_starts_with($s->key, 'impressum_html_') &&
|
||||
!str_starts_with($s->key, 'datenschutz_html_') &&
|
||||
!str_starts_with($s->key, 'password_reset_email_')
|
||||
@@ -62,29 +56,11 @@ class SettingsController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// 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'),
|
||||
];
|
||||
|
||||
$seasons = Season::orderByDesc('start_date')->get();
|
||||
|
||||
return view('admin.settings.edit', compact(
|
||||
'settings', 'eventDefaults', 'fileCategories', 'visibilitySettings',
|
||||
'isRegistered', 'installationId', 'updateInfo',
|
||||
'availableLocales', 'localeSettings', 'mailConfig', 'seasons'
|
||||
'settings', 'eventDefaults', 'fileCategories',
|
||||
'availableLocales', 'localeSettings', 'seasons'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -129,7 +105,7 @@ class SettingsController extends Controller
|
||||
|
||||
// Whitelist: Nur erlaubte Setting-Keys akzeptieren
|
||||
$allowedLocales = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
|
||||
$allowedPrefixes = ['default_min_', 'visibility_'];
|
||||
$allowedPrefixes = ['default_min_'];
|
||||
$allowedLocaleKeys = [];
|
||||
foreach ($allowedLocales as $loc) {
|
||||
$allowedLocaleKeys[] = "impressum_html_{$loc}";
|
||||
@@ -178,7 +154,7 @@ class SettingsController extends Controller
|
||||
$localeSetting->save();
|
||||
}
|
||||
} elseif ($isAllowedPrefix) {
|
||||
// Event-Defaults / Visibility: upsert — anlegen wenn nicht vorhanden
|
||||
// Event-Defaults: upsert — anlegen wenn nicht vorhanden
|
||||
$prefixSetting = new Setting([
|
||||
'label' => $key,
|
||||
'type' => 'number',
|
||||
@@ -194,240 +170,6 @@ class SettingsController extends Controller
|
||||
$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_player_stats')->delete();
|
||||
DB::table('event_carpool_passengers')->delete();
|
||||
DB::table('event_carpools')->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('logos');
|
||||
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_player_stats',
|
||||
'event_carpool_passengers', 'event_carpools', '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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class Event extends Model
|
||||
'start_at',
|
||||
'end_at',
|
||||
'status',
|
||||
'event_series_id',
|
||||
'location_name',
|
||||
'address_text',
|
||||
'location_lat',
|
||||
@@ -298,4 +299,16 @@ class Event extends Model
|
||||
{
|
||||
return $query->where('team_id', $teamId);
|
||||
}
|
||||
|
||||
public function isPartOfSeries(): bool
|
||||
{
|
||||
return $this->event_series_id !== null;
|
||||
}
|
||||
|
||||
public function followingSeriesEvents()
|
||||
{
|
||||
return static::where('event_series_id', $this->event_series_id)
|
||||
->where('start_at', '>', $this->start_at)
|
||||
->orderBy('start_at');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,25 @@ class Setting extends Model
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Feature global aktiviert ist (Master-Schalter).
|
||||
*/
|
||||
public static function isFeatureEnabled(string $feature): bool
|
||||
{
|
||||
return static::get("feature_{$feature}", '1') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Feature für den gegebenen User sichtbar ist.
|
||||
* Admin sieht immer alles.
|
||||
* Prüft zuerst den globalen Schalter, dann die Rollen-Sichtbarkeit.
|
||||
* Admin sieht alles, solange das Feature global aktiviert ist.
|
||||
*/
|
||||
public static function isFeatureVisibleFor(string $feature, User $user): bool
|
||||
{
|
||||
if (!static::isFeatureEnabled($feature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user