diff --git a/app/Filament/SuperAdmin/Pages/Auth/Login.php b/app/Filament/SuperAdmin/Pages/Auth/Login.php index c6711d5..31361e6 100644 --- a/app/Filament/SuperAdmin/Pages/Auth/Login.php +++ b/app/Filament/SuperAdmin/Pages/Auth/Login.php @@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms } // SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung - if ($user->role !== 'super_admin') { + if (! $user->isSuperAdmin()) { $authGuard->logout(); throw ValidationException::withMessages([ diff --git a/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php b/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php index 7eb87d5..b8d3096 100644 --- a/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php +++ b/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php @@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page public int $join_token_failure_decay_minutes = 5; - public int $join_token_access_limit = 120; + public int $join_token_access_limit = 300; public int $join_token_access_decay_minutes = 1; - public int $join_token_download_limit = 60; + public int $join_token_download_limit = 120; public int $join_token_download_decay_minutes = 1; @@ -69,9 +69,9 @@ class GuestPolicySettingsPage extends Page $this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50); $this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10); $this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5); - $this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120); + $this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300); $this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1); - $this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60); + $this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120); $this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1); $this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168); $this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48); diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 413a9a5..5c10775 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -16,6 +16,7 @@ use App\Models\Package; use App\Models\PackagePurchase; use App\Models\Photo; use App\Models\Tenant; +use App\Models\User; use App\Services\EventJoinTokenService; use App\Support\ApiError; use Illuminate\Http\JsonResponse; @@ -88,12 +89,15 @@ class EventController extends Controller $tenant = Tenant::findOrFail($tenantId); } + $actor = $request->user(); + $isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin(); + // Package check is now handled by middleware $validated = $request->validated(); $tenantId = $tenant->id; - $requestedPackageId = $validated['package_id'] ?? null; + $requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null; unset($validated['package_id']); $tenantPackage = $tenant->tenantPackages() @@ -108,6 +112,10 @@ class EventController extends Controller $package = Package::query()->find($requestedPackageId); } + if (! $package && $isSuperAdmin) { + $package = $this->resolveOwnerPackage(); + } + if (! $package && $tenantPackage) { $package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id); } @@ -121,7 +129,7 @@ class EventController extends Controller $requiresWaiver = $package->isEndcustomer(); $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; $existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null; - $needsWaiver = $requiresWaiver && ! $existingWaiver; + $needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver; if ($needsWaiver && ! $request->boolean('accepted_waiver')) { throw ValidationException::withMessages([ @@ -182,7 +190,7 @@ class EventController extends Controller $eventData = Arr::only($eventData, $allowed); - $event = DB::transaction(function () use ($tenant, $eventData, $package) { + $event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) { $event = Event::create($eventData); EventPackage::create([ @@ -193,7 +201,7 @@ class EventController extends Controller 'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, ]); - if ($package->isReseller()) { + if ($package->isReseller() && ! $isSuperAdmin) { $note = sprintf('Event #%d created (%s)', $event->id, $event->name); if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { @@ -229,6 +237,15 @@ class EventController extends Controller ->first(); } + private function resolveOwnerPackage(): ?Package + { + $ownerPackage = Package::query() + ->where('slug', 'pro') + ->first(); + + return $ownerPackage ?? Package::query()->find(3); + } + private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void { $timestamp = now(); diff --git a/app/Http/Controllers/Api/Tenant/EventMemberController.php b/app/Http/Controllers/Api/Tenant/EventMemberController.php index da05f3c..733114d 100644 --- a/app/Http/Controllers/Api/Tenant/EventMemberController.php +++ b/app/Http/Controllers/Api/Tenant/EventMemberController.php @@ -135,7 +135,7 @@ class EventMemberController extends Controller $user->password = Hash::make(Str::random(32)); } - if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') { + if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) { throw ValidationException::withMessages([ 'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'), ]); @@ -143,9 +143,9 @@ class EventMemberController extends Controller $user->tenant_id = $tenant->id; - if ($role === 'tenant_admin' && $user->role !== 'super_admin') { + if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) { $user->role = 'tenant_admin'; - } elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { + } elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) { $user->role = 'member'; } diff --git a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php index 6978af4..8997d72 100644 --- a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php +++ b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php @@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller $abilities[] = 'tenant:'.$user->tenant_id; } - if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { + if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) { $abilities[] = 'tenant-admin'; } - if ($user->role === 'super_admin') { + if ($user->isSuperAdmin()) { $abilities[] = 'super-admin'; } @@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller private function ensureUserCanAccessPanel(User $user): void { - if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { + if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) { return; } diff --git a/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php b/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php index fedac7d..a7f1880 100644 --- a/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php +++ b/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php @@ -9,8 +9,8 @@ use App\Models\User; use App\Notifications\TenantFeedbackSubmitted; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Validation\Rule; use Illuminate\Support\Facades\Notification; +use Illuminate\Validation\Rule; class TenantFeedbackController extends Controller { @@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller ]); $recipients = User::query() - ->where('role', 'super_admin') + ->whereIn('role', ['super_admin', 'superadmin']) ->whereNotNull('email') ->get(); diff --git a/app/Http/Controllers/Api/TenantAuth/TenantAdminPasswordResetController.php b/app/Http/Controllers/Api/TenantAuth/TenantAdminPasswordResetController.php index acf29aa..3ad5fbb 100644 --- a/app/Http/Controllers/Api/TenantAuth/TenantAdminPasswordResetController.php +++ b/app/Http/Controllers/Api/TenantAuth/TenantAdminPasswordResetController.php @@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller private function canAccessEventAdmin(User $user): bool { - if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { + if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) { return true; } diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index abc82fe..e80184e 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -155,7 +155,7 @@ class AuthenticatedSessionController extends Controller } // Super admins go to Filament superadmin panel - if ($user && $user->role === 'super_admin') { + if ($user && $user->isSuperAdmin()) { return '/super-admin'; } diff --git a/app/Http/Controllers/TenantAdminAuthController.php b/app/Http/Controllers/TenantAdminAuthController.php index 9ea3366..5bf898b 100644 --- a/app/Http/Controllers/TenantAdminAuthController.php +++ b/app/Http/Controllers/TenantAdminAuthController.php @@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller $user = Auth::user(); // Allow only tenant_admin and super_admin - if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) { + if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) { return view('admin'); } diff --git a/app/Http/Controllers/TenantAdminGoogleController.php b/app/Http/Controllers/TenantAdminGoogleController.php index 28e278f..06d5a67 100644 --- a/app/Http/Controllers/TenantAdminGoogleController.php +++ b/app/Http/Controllers/TenantAdminGoogleController.php @@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller /** @var User|null $user */ $user = User::query()->where('email', $email)->first(); - if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { + if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) { return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.'); } diff --git a/app/Http/Middleware/CreditCheckMiddleware.php b/app/Http/Middleware/CreditCheckMiddleware.php index 1c49b6c..e9a5c5e 100644 --- a/app/Http/Middleware/CreditCheckMiddleware.php +++ b/app/Http/Middleware/CreditCheckMiddleware.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Models\Tenant; +use App\Models\User; use App\Services\Packages\PackageLimitEvaluator; use App\Support\ApiError; use Closure; @@ -26,7 +27,7 @@ class CreditCheckMiddleware ]); } - if ($this->requiresCredits($request)) { + if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) { $violation = $this->limitEvaluator->assessEventCreation($tenant); if ($violation !== null) { @@ -43,6 +44,24 @@ class CreditCheckMiddleware return $next($request); } + private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool + { + $user = $request->user(); + if (! $user instanceof User) { + return false; + } + + if (! $user->isSuperAdmin()) { + return false; + } + + if (! $user->tenant_id) { + return false; + } + + return (int) $user->tenant_id === (int) $tenant->id; + } + private function requiresCredits(Request $request): bool { return $request->isMethod('post') diff --git a/app/Http/Middleware/EnsureTenantAdminToken.php b/app/Http/Middleware/EnsureTenantAdminToken.php index d0586b1..8805443 100644 --- a/app/Http/Middleware/EnsureTenantAdminToken.php +++ b/app/Http/Middleware/EnsureTenantAdminToken.php @@ -42,7 +42,7 @@ class EnsureTenantAdminToken /** @var Tenant|null $tenant */ $tenant = $user->tenant; - if (! $tenant && $user->role === 'super_admin') { + if (! $tenant && $user->isSuperAdmin()) { $requestedTenantId = $this->resolveRequestedTenantId($request); if ($requestedTenantId !== null) { @@ -50,14 +50,14 @@ class EnsureTenantAdminToken } } - if (! $tenant && $user->role !== 'super_admin') { + if (! $tenant && ! $user->isSuperAdmin()) { return $this->forbiddenResponse('Tenant context missing for user.'); } if ($tenant) { $request->attributes->set('tenant_id', $tenant->id); $request->attributes->set('tenant', $tenant); - } elseif ($user->role === 'super_admin') { + } elseif ($user->isSuperAdmin()) { $requestedTenantId = $this->resolveRequestedTenantId($request); if ($requestedTenantId !== null) { $request->attributes->set('tenant_id', $requestedTenantId); @@ -96,7 +96,7 @@ class EnsureTenantAdminToken */ protected function allowedRoles(): array { - return ['tenant_admin', 'super_admin', 'admin']; + return ['tenant_admin', 'super_admin', 'superadmin', 'admin']; } protected function forbiddenRoleMessage(): string diff --git a/app/Http/Middleware/EnsureTenantCollaboratorToken.php b/app/Http/Middleware/EnsureTenantCollaboratorToken.php index df474c6..77d6190 100644 --- a/app/Http/Middleware/EnsureTenantCollaboratorToken.php +++ b/app/Http/Middleware/EnsureTenantCollaboratorToken.php @@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken { protected function allowedRoles(): array { - return ['tenant_admin', 'super_admin', 'admin', 'member']; + return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member']; } protected function forbiddenRoleMessage(): string diff --git a/app/Http/Middleware/PackageMiddleware.php b/app/Http/Middleware/PackageMiddleware.php index e2b91b4..093e700 100644 --- a/app/Http/Middleware/PackageMiddleware.php +++ b/app/Http/Middleware/PackageMiddleware.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Models\Tenant; +use App\Models\User; use App\Services\Packages\PackageLimitEvaluator; use App\Support\ApiError; use Closure; @@ -26,7 +27,7 @@ class PackageMiddleware ]); } - if ($this->requiresPackageCheck($request)) { + if ($this->requiresPackageCheck($request) && ! $this->shouldBypassPackageCheck($request, $tenant)) { $violation = $this->detectViolation($request, $tenant); if ($violation !== null) { @@ -43,6 +44,24 @@ class PackageMiddleware return $next($request); } + private function shouldBypassPackageCheck(Request $request, Tenant $tenant): bool + { + $user = $request->user(); + if (! $user instanceof User) { + return false; + } + + if (! $user->isSuperAdmin()) { + return false; + } + + if (! $user->tenant_id) { + return false; + } + + return (int) $user->tenant_id === (int) $tenant->id; + } + private function requiresPackageCheck(Request $request): bool { return $request->isMethod('post') && ( diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index fa0d496..e98f509 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware return '/event-admin/dashboard'; } - if ($user && $user->role === 'super_admin') { + if ($user && $user->isSuperAdmin()) { return '/super-admin'; } diff --git a/app/Http/Middleware/SuperAdminAuth.php b/app/Http/Middleware/SuperAdminAuth.php index 1678b31..9e157c7 100644 --- a/app/Http/Middleware/SuperAdminAuth.php +++ b/app/Http/Middleware/SuperAdminAuth.php @@ -4,9 +4,9 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\Response; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; +use Symfony\Component\HttpFoundation\Response; class SuperAdminAuth { @@ -21,17 +21,17 @@ class SuperAdminAuth return $next($request); } - if (!Auth::check()) { + if (! Auth::check()) { abort(403, 'Nicht angemeldet.'); } $user = Auth::user(); - Log::info('SuperAdminAuth: User ID ' . $user->id . ', role: ' . $user->role); + Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role); - if ($user->role !== 'super_admin') { - abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role); + if (! $user->isSuperAdmin()) { + abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role); } return $next($request); } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index deb5009..c76fe09 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -30,6 +30,7 @@ class EventStoreRequest extends FormRequest 'event_date' => ['required', 'date', 'after_or_equal:today'], 'location' => ['nullable', 'string', 'max:255'], 'event_type_id' => ['required', 'exists:event_types,id'], + 'package_id' => ['nullable', 'integer', 'exists:packages,id'], 'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'], 'public_url' => ['nullable', 'url', 'max:500'], 'custom_domain' => ['nullable', 'string', 'max:255'], diff --git a/app/Models/GuestPolicySetting.php b/app/Models/GuestPolicySetting.php index 92b1c8a..c32735b 100644 --- a/app/Models/GuestPolicySetting.php +++ b/app/Models/GuestPolicySetting.php @@ -41,9 +41,9 @@ class GuestPolicySetting extends Model 'per_device_upload_limit' => 50, 'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10), 'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5), - 'join_token_access_limit' => (int) config('join_tokens.access_limit', 120), + 'join_token_access_limit' => (int) config('join_tokens.access_limit', 300), 'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1), - 'join_token_download_limit' => (int) config('join_tokens.download_limit', 60), + 'join_token_download_limit' => (int) config('join_tokens.download_limit', 120), 'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1), 'join_token_ttl_hours' => 168, 'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48), diff --git a/app/Models/User.php b/app/Models/User.php index b53683c..9591f8b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -69,6 +69,16 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser, ]; } + public function isSuperAdmin(): bool + { + return self::isSuperAdminRole($this->role); + } + + public static function isSuperAdminRole(?string $role): bool + { + return in_array($role, ['super_admin', 'superadmin'], true); + } + /** * Retrieve the user by the given credentials. */ @@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser, public function canAccessPanel(Panel $panel): bool { - if (! $this->email_verified_at && $this->role !== 'super_admin') { + if (! $this->email_verified_at && ! $this->isSuperAdmin()) { return false; } return match ($panel->getId()) { - 'superadmin' => $this->role === 'super_admin', + 'superadmin' => $this->isSuperAdmin(), 'admin' => $this->role === 'tenant_admin', default => false, }; @@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser, public function canAccessTenant(Model $tenant): bool { - if ($this->role === 'super_admin') { + if ($this->isSuperAdmin()) { return true; } @@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser, public function getTenants(Panel $panel): array|Collection { - if ($this->role === 'super_admin') { + if ($this->isSuperAdmin()) { return Tenant::query()->orderBy('name')->get(); } diff --git a/app/Policies/PurchaseHistoryPolicy.php b/app/Policies/PurchaseHistoryPolicy.php index 60cd8f9..2e63737 100644 --- a/app/Policies/PurchaseHistoryPolicy.php +++ b/app/Policies/PurchaseHistoryPolicy.php @@ -12,12 +12,11 @@ class PurchaseHistoryPolicy public function viewAny(User $user): bool { - return $user->role === 'super_admin'; + return $user->isSuperAdmin(); } public function view(User $user, PurchaseHistory $purchaseHistory): bool { - return $user->role === 'super_admin'; + return $user->isSuperAdmin(); } } - diff --git a/app/Policies/TenantPolicy.php b/app/Policies/TenantPolicy.php index 5bb1c1a..18acfe3 100644 --- a/app/Policies/TenantPolicy.php +++ b/app/Policies/TenantPolicy.php @@ -15,7 +15,7 @@ class TenantPolicy */ public function viewAny(User $user): bool { - return $user->role === 'super_admin'; + return $user->isSuperAdmin(); } /** @@ -35,7 +35,7 @@ class TenantPolicy */ public function create(User $user): bool { - return $user->role === 'super_admin'; + return $user->isSuperAdmin(); } /** @@ -43,7 +43,7 @@ class TenantPolicy */ public function update(User $user, Tenant $tenant): bool { - return $user->role === 'super_admin'; + return $user->isSuperAdmin(); } /** @@ -51,7 +51,7 @@ class TenantPolicy */ public function delete(User $user, Tenant $tenant): bool { - return $user->role === 'super_admin'; + return $user->isSuperAdmin(); } /** @@ -59,6 +59,6 @@ class TenantPolicy */ public function suspend(User $user, Tenant $tenant): bool { - return $user->role === 'super_admin'; + return $user->isSuperAdmin(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index afa8356..64ede0a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -155,7 +155,11 @@ class AppServiceProvider extends ServiceProvider $key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown')); - return Limit::perMinute(100)->by($key); + return Limit::perMinute(600)->by($key); + }); + + RateLimiter::for('guest-api', function (Request $request) { + return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown')); }); RateLimiter::for('tenant-auth', function (Request $request) { diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 703b1a0..a55e429 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider }); Gate::before(function (User $user): ?bool { - return $user->role === 'super_admin' ? true : null; + return $user->isSuperAdmin() ? true : null; }); } } diff --git a/app/Services/Audit/SuperAdminAuditLogger.php b/app/Services/Audit/SuperAdminAuditLogger.php index bfb54d0..4ebd942 100644 --- a/app/Services/Audit/SuperAdminAuditLogger.php +++ b/app/Services/Audit/SuperAdminAuditLogger.php @@ -83,7 +83,7 @@ class SuperAdminAuditLogger private function shouldLog(?User $actor): bool { - if (! $actor || $actor->role !== 'super_admin') { + if (! $actor || ! $actor->isSuperAdmin()) { return false; } diff --git a/app/Support/TenantAuth.php b/app/Support/TenantAuth.php index 82d5d39..81e419e 100644 --- a/app/Support/TenantAuth.php +++ b/app/Support/TenantAuth.php @@ -24,15 +24,15 @@ class TenantAuth } $user = $request->user(); - if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) { - if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) { + if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) { + if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) { return $user; } } $user = User::query() ->where('tenant_id', $tenantId) - ->whereIn('role', ['tenant_admin', 'admin', 'member']) + ->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member']) ->orderByDesc('email_verified_at') ->orderBy('id') ->first(); diff --git a/config/join_tokens.php b/config/join_tokens.php index eb535f2..cb3e3b4 100644 --- a/config/join_tokens.php +++ b/config/join_tokens.php @@ -4,9 +4,9 @@ return [ 'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10), 'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5), - 'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120), + 'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 300), 'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1), - 'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60), + 'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 120), 'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1), ]; diff --git a/database/seeders/SuperAdminSeeder.php b/database/seeders/SuperAdminSeeder.php index b07414a..47d752e 100644 --- a/database/seeders/SuperAdminSeeder.php +++ b/database/seeders/SuperAdminSeeder.php @@ -2,9 +2,11 @@ namespace Database\Seeders; +use App\Models\Tenant; +use App\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; -use App\Models\User; +use Illuminate\Support\Str; class SuperAdminSeeder extends Seeder { @@ -12,12 +14,49 @@ class SuperAdminSeeder extends Seeder { $email = env('ADMIN_EMAIL', 'admin@example.com'); $password = env('ADMIN_PASSWORD', 'ChangeMe123!'); - User::updateOrCreate(['email'=>$email], [ + $user = User::updateOrCreate(['email' => $email], [ 'first_name' => 'Super', 'last_name' => 'Admin', 'password' => Hash::make($password), 'role' => 'super_admin', ]); + + $tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant'); + $tenantName = env('OWNER_TENANT_NAME', 'Owner Tenant'); + + $tenant = Tenant::query()->firstOrCreate( + ['slug' => $tenantSlug], + [ + 'name' => $tenantName, + 'email' => $email, + 'contact_email' => $email, + 'user_id' => $user->id, + 'is_active' => true, + 'is_suspended' => false, + 'settings' => [ + 'contact_email' => $email, + ], + ], + ); + + if (! $tenant->slug) { + $tenant->forceFill(['slug' => Str::slug($tenantName)])->save(); + } + + if (! $tenant->user_id) { + $tenant->forceFill(['user_id' => $user->id])->save(); + } + + if (! $tenant->email) { + $tenant->forceFill(['email' => $email])->save(); + } + + if (! $tenant->contact_email) { + $tenant->forceFill(['contact_email' => $email])->save(); + } + + if ($user->tenant_id !== $tenant->id) { + $user->forceFill(['tenant_id' => $tenant->id])->save(); + } } } - diff --git a/playwright-report/index.html b/playwright-report/index.html index 49893aa..f3ac1d9 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+n.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/resources/js/admin/mobile/EventAnalyticsPage.tsx b/resources/js/admin/mobile/EventAnalyticsPage.tsx index 01db187..00c3694 100644 --- a/resources/js/admin/mobile/EventAnalyticsPage.tsx +++ b/resources/js/admin/mobile/EventAnalyticsPage.tsx @@ -14,13 +14,11 @@ import { getEventAnalytics, EventAnalytics } from '../api'; import { ApiError } from '../lib/apiError'; import { useAdminTheme } from './theme'; import { adminPath } from '../constants'; -import { useEventContext } from '../context/EventContext'; export default function MobileEventAnalyticsPage() { const { slug } = useParams<{ slug: string }>(); const { t, i18n } = useTranslation('management'); const navigate = useNavigate(); - const { activeEvent } = useEventContext(); const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme(); const dateLocale = i18n.language.startsWith('de') ? de : enGB; @@ -106,7 +104,6 @@ export default function MobileEventAnalyticsPage() { return ( navigate(-1)} > diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index 73b9180..2935166 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -9,7 +9,7 @@ import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton } from './components/Primitives'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { LegalConsentSheet } from './components/LegalConsentSheet'; -import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType, trackOnboarding } from '../api'; +import { createEvent, getEvent, updateEvent, getEventTypes, getPackages, Package, TenantEvent, TenantEventType, trackOnboarding } from '../api'; import { resolveEventSlugAfterUpdate } from './eventFormNavigation'; import { adminPath } from '../constants'; import { isAuthError } from '../auth/tokens'; @@ -18,6 +18,7 @@ import toast from 'react-hot-toast'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { withAlpha } from './components/colors'; +import { useAuth } from '../auth/context'; type FormState = { name: string; @@ -28,6 +29,7 @@ type FormState = { published: boolean; autoApproveUploads: boolean; tasksEnabled: boolean; + packageId: number | null; }; export default function MobileEventFormPage() { @@ -36,7 +38,9 @@ export default function MobileEventFormPage() { const isEdit = Boolean(slug); const navigate = useNavigate(); const { t } = useTranslation(['management', 'common']); + const { user } = useAuth(); const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme(); + const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin'; const [form, setForm] = React.useState({ name: '', @@ -47,9 +51,12 @@ export default function MobileEventFormPage() { published: false, autoApproveUploads: true, tasksEnabled: true, + packageId: null, }); const [eventTypes, setEventTypes] = React.useState([]); const [typesLoading, setTypesLoading] = React.useState(false); + const [packages, setPackages] = React.useState([]); + const [packagesLoading, setPackagesLoading] = React.useState(false); const [loading, setLoading] = React.useState(isEdit); const [saving, setSaving] = React.useState(false); const [consentOpen, setConsentOpen] = React.useState(false); @@ -76,6 +83,7 @@ export default function MobileEventFormPage() { tasksEnabled: (data.settings?.engagement_mode as string | undefined) !== 'photo_only' && (data.engagement_mode as string | undefined) !== 'photo_only', + packageId: null, }); setError(null); } catch (err) { @@ -106,6 +114,31 @@ export default function MobileEventFormPage() { })(); }, []); + React.useEffect(() => { + if (!isSuperAdmin || isEdit) { + return; + } + + (async () => { + setPackagesLoading(true); + try { + const data = await getPackages('endcustomer'); + setPackages(data); + setForm((prev) => { + if (prev.packageId) { + return prev; + } + const preferred = data.find((pkg) => pkg.id === 3) ?? data[0] ?? null; + return { ...prev, packageId: preferred?.id ?? null }; + }); + } catch { + setPackages([]); + } finally { + setPackagesLoading(false); + } + })(); + }, [isSuperAdmin, isEdit]); + async function handleSubmit() { setSaving(true); setError(null); @@ -131,6 +164,7 @@ export default function MobileEventFormPage() { event_type_id: form.eventTypeId ?? undefined, event_date: form.date || undefined, status: form.published ? 'published' : 'draft', + package_id: isSuperAdmin ? form.packageId ?? undefined : undefined, settings: { location: form.location, guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', @@ -153,6 +187,7 @@ export default function MobileEventFormPage() { event_type_id: form.eventTypeId ?? undefined, event_date: form.date || undefined, status: form.published ? 'published' : 'draft', + package_id: isSuperAdmin ? form.packageId ?? undefined : undefined, settings: { location: form.location, guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', @@ -223,6 +258,31 @@ export default function MobileEventFormPage() { /> + {isSuperAdmin && !isEdit ? ( + + {packagesLoading ? ( + {t('eventForm.fields.package.loading', 'Loading packages…')} + ) : packages.length === 0 ? ( + {t('eventForm.fields.package.empty', 'No packages available yet.')} + ) : ( + setForm((prev) => ({ ...prev, packageId: Number(e.target.value) }))} + > + + {packages.map((pkg) => ( + + ))} + + )} + + {t('eventForm.fields.package.help', 'This controls the event’s premium limits.')} + + + ) : null} + load()} ariaLabel={t('common.refresh', 'Refresh')}> diff --git a/resources/js/admin/mobile/EventPhotoboothPage.tsx b/resources/js/admin/mobile/EventPhotoboothPage.tsx index 61cc865..79c8124 100644 --- a/resources/js/admin/mobile/EventPhotoboothPage.tsx +++ b/resources/js/admin/mobile/EventPhotoboothPage.tsx @@ -19,7 +19,7 @@ import { } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; -import { formatEventDate, resolveEventDisplayName } from '../lib/events'; +import { formatEventDate } from '../lib/events'; import toast from 'react-hot-toast'; import { adminPath } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; @@ -146,9 +146,7 @@ export default function MobileEventPhotoboothPage() { : t('photobooth.credentials.heading', 'FTP (Classic)'); const isActive = Boolean(status?.enabled); - const title = event ? resolveEventDisplayName(event) : t('management.header.appName', 'Event Admin'); - const subtitle = - event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue'); + const title = t('photobooth.title', 'Photobooth'); const handleToggle = (checked: boolean) => { if (!slug || updating) return; @@ -163,7 +161,6 @@ export default function MobileEventPhotoboothPage() { load()} ariaLabel={t('common.refresh', 'Refresh')}> diff --git a/resources/js/admin/mobile/EventRecapPage.tsx b/resources/js/admin/mobile/EventRecapPage.tsx index 21c04e9..d92917c 100644 --- a/resources/js/admin/mobile/EventRecapPage.tsx +++ b/resources/js/admin/mobile/EventRecapPage.tsx @@ -152,7 +152,6 @@ export default function MobileEventRecapPage() { @@ -392,4 +391,4 @@ function formatDate(iso?: string | null): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }); -} \ No newline at end of file +} diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index e36256c..07b3429 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -691,6 +691,7 @@ export default function MobileNotificationsPage() { } }} title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')} + snapPoints={[94]} footer={ selectedNotification && !selectedNotification.is_read ? ( markSelectedRead()} /> @@ -705,7 +706,7 @@ export default function MobileNotificationsPage() { {selectedNotification.body} - + {selectedNotification.scope} diff --git a/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx b/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx index ea35d78..6ea6782 100644 --- a/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx @@ -19,6 +19,7 @@ vi.mock('../../api', () => ({ getEvent: vi.fn(), updateEvent: vi.fn(), getEventTypes: vi.fn().mockResolvedValue([]), + getPackages: vi.fn().mockResolvedValue([]), trackOnboarding: vi.fn(), })); @@ -81,6 +82,10 @@ vi.mock('../theme', () => ({ }), })); +vi.mock('../../auth/context', () => ({ + useAuth: () => ({ user: { role: 'tenant_admin' } }), +})); + import { getEventTypes } from '../../api'; import MobileEventFormPage from '../EventFormPage'; diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx index e6cfed8..0f6005c 100644 --- a/resources/js/admin/mobile/components/MobileShell.tsx +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -1,6 +1,6 @@ import React, { Suspense } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronLeft, Bell, QrCode } from 'lucide-react'; +import { ChevronLeft, Bell, QrCode } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; @@ -9,11 +9,10 @@ import { useEventContext } from '../../context/EventContext'; import { BottomNav, NavKey } from './BottomNav'; import { useMobileNav } from '../hooks/useMobileNav'; import { adminPath } from '../../constants'; -import { MobileSheet } from './Sheet'; -import { MobileCard, PillBadge, CTAButton } from './Primitives'; +import { MobileCard, CTAButton } from './Primitives'; import { useNotificationsBadge } from '../hooks/useNotificationsBadge'; import { useOnlineStatus } from '../hooks/useOnlineStatus'; -import { formatEventDate, resolveEventDisplayName } from '../../lib/events'; +import { resolveEventDisplayName } from '../../lib/events'; import { TenantEvent, getEvents } from '../../api'; import { withAlpha } from './colors'; import { setTabHistory } from '../lib/tabHistory'; @@ -31,11 +30,11 @@ type MobileShellProps = { }; export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) { - const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext(); + const { events, activeEvent, selectEvent } = useEventContext(); const { go } = useMobileNav(activeEvent?.slug, activeTab); const navigate = useNavigate(); const location = useLocation(); - const { t, i18n } = useTranslation('mobile'); + const { t } = useTranslation('mobile'); const { count: notificationCount } = useNotificationsBadge(); const online = useOnlineStatus(); const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme(); @@ -45,16 +44,13 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head const textColor = text; const mutedText = muted; const headerSurface = withAlpha(surfaceColor, 0.94); - const [pickerOpen, setPickerOpen] = React.useState(false); const [fallbackEvents, setFallbackEvents] = React.useState([]); const [loadingEvents, setLoadingEvents] = React.useState(false); const [attemptedFetch, setAttemptedFetch] = React.useState(false); const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0); + const [isCompactHeader, setIsCompactHeader] = React.useState(false); - const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; const effectiveEvents = events.length ? events : fallbackEvents; - const effectiveHasMultiple = hasMultipleEvents || effectiveEvents.length > 1; - const effectiveHasEvents = hasEvents || effectiveEvents.length > 0; const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null); React.useEffect(() => { @@ -74,16 +70,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head .finally(() => setLoadingEvents(false)); }, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]); - React.useEffect(() => { - if (!pickerOpen) return; - if (effectiveEvents.length) return; - setLoadingEvents(true); - getEvents({ force: true }) - .then((list) => setFallbackEvents(list ?? [])) - .catch(() => setFallbackEvents([])) - .finally(() => setLoadingEvents(false)); - }, [pickerOpen, effectiveEvents.length]); - React.useEffect(() => { const path = `${location.pathname}${location.search}${location.hash}`; @@ -114,17 +100,104 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head }; }, [refreshQueuedActions]); - const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin')); - const subtitleText = - subtitle ?? - (effectiveActive?.event_date - ? formatEventDate(effectiveActive.event_date, locale) ?? '' - : effectiveHasEvents - ? t('header.selectEvent', 'Select an event to continue') - : t('header.empty', 'Create your first event to get started')); + React.useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) { + return; + } + const query = window.matchMedia('(max-width: 520px)'); + const handleChange = (event: MediaQueryListEvent) => { + setIsCompactHeader(event.matches); + }; + setIsCompactHeader(query.matches); + query.addEventListener?.('change', handleChange); + return () => { + query.removeEventListener?.('change', handleChange); + }; + }, []); - const showEventSwitcher = effectiveHasMultiple; + const pageTitle = title ?? t('header.appName', 'Event Admin'); + const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null; + const subtitleText = subtitle ?? eventContext ?? ''; const showQr = Boolean(effectiveActive?.slug); + const headerBackButton = onBack ? ( + + + + + + ) : ( + + ); + const headerTitle = ( + + + + {pageTitle} + + {subtitleText ? ( + + {subtitleText} + + ) : null} + + + ); + const headerActionsRow = ( + + navigate(adminPath('/mobile/notifications'))} + ariaLabel={t('mobile.notifications', 'Notifications')} + > + + + {notificationCount > 0 ? ( + + + {notificationCount > 9 ? '9+' : notificationCount} + + + ) : null} + + + {showQr ? ( + navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))} + ariaLabel={t('header.quickQr', 'Quick QR')} + > + + + + + ) : null} + {headerActions ?? null} + + ); return ( @@ -150,96 +223,27 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head WebkitBackdropFilter: 'blur(12px)', }} > - - {onBack ? ( - - - + {isCompactHeader ? ( + + + {headerBackButton} + + {headerTitle} - - ) : ( - - )} - - - - setPickerOpen(true)} - style={{ alignItems: 'flex-end' }} - > - - {eventTitle} - - {subtitleText ? ( - - {subtitleText} - - ) : null} - - {showEventSwitcher ? : null} - - - navigate(adminPath('/mobile/notifications'))} - ariaLabel={t('mobile.notifications', 'Notifications')} - > - - - {notificationCount > 0 ? ( - - - {notificationCount > 9 ? '9+' : notificationCount} - - - ) : null} - - - {showQr ? ( - navigate(adminPath(`/mobile/events/${effectiveActive?.slug}/qr`))} - ariaLabel={t('header.quickQr', 'Quick QR')} - > - - - - {t('header.quickQr', 'Quick QR')} - - - - ) : null} - {headerActions ?? null} + + {headerActionsRow} + + + ) : ( + + {headerBackButton} + + {headerTitle} + {headerActionsRow} - + )} - setPickerOpen(false)} - title={t('header.eventSwitcher', 'Choose an event')} - footer={null} - bottomOffsetPx={110} - > - - {effectiveEvents.length === 0 ? ( - - - {t('header.noEventsTitle', 'Create your first event')} - - - {t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')} - - navigate(adminPath('/mobile/events/new'))}> - - - {t('header.createEvent', 'Create event')} - - - - - ) : ( - effectiveEvents.map((event) => ( - { - const targetSlug = event.slug ?? null; - selectEvent(targetSlug); - setPickerOpen(false); - if (targetSlug) { - navigate(adminPath(`/mobile/events/${targetSlug}`)); - } - }} - > - - - - {resolveEventDisplayName(event)} - - - {formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')} - - - - {event.slug === activeEvent?.slug - ? t('header.active', 'Active') - : (event.status ?? '—')} - - - - )) - )} - {activeEvent ? ( - { - selectEvent(null); - setPickerOpen(false); - }} - > - - {t('header.clearSelection', 'Clear selection')} - - - ) : null} - - ); } diff --git a/resources/js/admin/mobile/components/Sheet.test.tsx b/resources/js/admin/mobile/components/Sheet.test.tsx index 93a8692..a0aafbd 100644 --- a/resources/js/admin/mobile/components/Sheet.test.tsx +++ b/resources/js/admin/mobile/components/Sheet.test.tsx @@ -50,7 +50,7 @@ describe('MobileSheet', () => { const onClose = vi.fn(); render( - +
Body
, ); diff --git a/resources/js/admin/mobile/components/Sheet.tsx b/resources/js/admin/mobile/components/Sheet.tsx index 3712999..623fe21 100644 --- a/resources/js/admin/mobile/components/Sheet.tsx +++ b/resources/js/admin/mobile/components/Sheet.tsx @@ -12,11 +12,26 @@ type SheetProps = { onClose: () => void; children: React.ReactNode; footer?: React.ReactNode; + snapPoints?: number[]; + contentSpacing?: string; + padding?: string; + paddingBottom?: string; /** Optional bottom offset so content sits above the bottom nav/safe-area. */ bottomOffsetPx?: number; }; -export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) { +export function MobileSheet({ + open, + title, + onClose, + children, + footer, + snapPoints = [82], + contentSpacing = '$3', + padding = '$4', + paddingBottom = '$7', + bottomOffsetPx = 88, +}: SheetProps) { const { t } = useTranslation('mobile'); const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme(); const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`; @@ -33,7 +48,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs onClose(); } }} - snapPoints={[82]} + snapPoints={snapPoints} snapPointsMode="percent" dismissOnOverlayPress dismissOnSnapToBottom @@ -48,8 +63,8 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs borderTopLeftRadius: 24, borderTopRightRadius: 24, backgroundColor: surface, - padding: '$4', - paddingBottom: '$7', + padding, + paddingBottom, shadowColor: shadow, shadowOpacity: 0.12, shadowRadius: 18, @@ -62,7 +77,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs showsVerticalScrollIndicator={false} {...({ contentContainerStyle: { paddingBottom: 6 } } as any)} > - + {title} diff --git a/routes/api.php b/routes/api.php index 3a3e1a7..e766285 100644 --- a/routes/api.php +++ b/routes/api.php @@ -88,7 +88,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { }); }); - Route::middleware('throttle:100,1')->group(function () { + Route::middleware('throttle:guest-api')->group(function () { Route::get('/help', [HelpController::class, 'index'])->name('help.index'); Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show'); Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show'); diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index 1bc1261..2bccc50 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -11,6 +11,7 @@ use App\Models\TenantPackage; use App\Services\EventJoinTokenService; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; use Tests\Feature\Tenant\TenantTestCase; @@ -77,6 +78,55 @@ class EventControllerTest extends TenantTestCase ->assertJsonPath('error.code', 'event_limit_missing'); } + public function test_superadmin_can_create_event_without_tenant_package(): void + { + $tenant = $this->tenant; + $eventType = EventType::factory()->create(); + $package = Package::factory()->create([ + 'type' => 'endcustomer', + 'slug' => 'pro', + 'max_photos' => 100, + ]); + + $superadmin = \App\Models\User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'superadmin', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ]); + + $login = $this->postJson('/api/v1/tenant-auth/login', [ + 'login' => $superadmin->email, + 'password' => 'password', + ]); + + $login->assertOk(); + $token = (string) $login->json('token'); + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/tenant/events', [ + 'name' => 'Owner Event', + 'slug' => 'owner-event', + 'event_date' => Carbon::now()->addDays(10)->toDateString(), + 'event_type_id' => $eventType->id, + 'package_id' => $package->id, + ]); + + $response->assertStatus(201); + + $event = Event::latest()->first(); + + $this->assertDatabaseHas('events', [ + 'tenant_id' => $tenant->id, + 'slug' => 'owner-event', + ]); + + $this->assertDatabaseHas('event_packages', [ + 'event_id' => $event->id, + 'package_id' => $package->id, + ]); + } + public function test_create_event_requires_waiver_for_endcustomer_package(): void { $tenant = $this->tenant;