Misc unrelated updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-12 10:31:31 +01:00
parent e9afbeb028
commit 3de1d3deab
40 changed files with 433 additions and 267 deletions

View File

@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
} }
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung // SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
if ($user->role !== 'super_admin') { if (! $user->isSuperAdmin()) {
$authGuard->logout(); $authGuard->logout();
throw ValidationException::withMessages([ throw ValidationException::withMessages([

View File

@@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page
public int $join_token_failure_decay_minutes = 5; 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_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; 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->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_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_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_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_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168); $this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48); $this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);

View File

@@ -16,6 +16,7 @@ use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\Photo; use App\Models\Photo;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Support\ApiError; use App\Support\ApiError;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -88,12 +89,15 @@ class EventController extends Controller
$tenant = Tenant::findOrFail($tenantId); $tenant = Tenant::findOrFail($tenantId);
} }
$actor = $request->user();
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
// Package check is now handled by middleware // Package check is now handled by middleware
$validated = $request->validated(); $validated = $request->validated();
$tenantId = $tenant->id; $tenantId = $tenant->id;
$requestedPackageId = $validated['package_id'] ?? null; $requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']); unset($validated['package_id']);
$tenantPackage = $tenant->tenantPackages() $tenantPackage = $tenant->tenantPackages()
@@ -108,6 +112,10 @@ class EventController extends Controller
$package = Package::query()->find($requestedPackageId); $package = Package::query()->find($requestedPackageId);
} }
if (! $package && $isSuperAdmin) {
$package = $this->resolveOwnerPackage();
}
if (! $package && $tenantPackage) { if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id); $package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
} }
@@ -121,7 +129,7 @@ class EventController extends Controller
$requiresWaiver = $package->isEndcustomer(); $requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : 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')) { if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -182,7 +190,7 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed); $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); $event = Event::create($eventData);
EventPackage::create([ EventPackage::create([
@@ -193,7 +201,7 @@ class EventController extends Controller
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, '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); $note = sprintf('Event #%d created (%s)', $event->id, $event->name);
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
@@ -229,6 +237,15 @@ class EventController extends Controller
->first(); ->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 private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
{ {
$timestamp = now(); $timestamp = now();

View File

@@ -135,7 +135,7 @@ class EventMemberController extends Controller
$user->password = Hash::make(Str::random(32)); $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([ throw ValidationException::withMessages([
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'), 'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
]); ]);
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
$user->tenant_id = $tenant->id; $user->tenant_id = $tenant->id;
if ($role === 'tenant_admin' && $user->role !== 'super_admin') { if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
$user->role = 'tenant_admin'; $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'; $user->role = 'member';
} }

View File

@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
$abilities[] = 'tenant:'.$user->tenant_id; $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'; $abilities[] = 'tenant-admin';
} }
if ($user->role === 'super_admin') { if ($user->isSuperAdmin()) {
$abilities[] = 'super-admin'; $abilities[] = 'super-admin';
} }
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
private function ensureUserCanAccessPanel(User $user): void 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; return;
} }

View File

@@ -9,8 +9,8 @@ use App\Models\User;
use App\Notifications\TenantFeedbackSubmitted; use App\Notifications\TenantFeedbackSubmitted;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\Rule;
class TenantFeedbackController extends Controller class TenantFeedbackController extends Controller
{ {
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
]); ]);
$recipients = User::query() $recipients = User::query()
->where('role', 'super_admin') ->whereIn('role', ['super_admin', 'superadmin'])
->whereNotNull('email') ->whereNotNull('email')
->get(); ->get();

View File

@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
private function canAccessEventAdmin(User $user): bool 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; return true;
} }

View File

@@ -155,7 +155,7 @@ class AuthenticatedSessionController extends Controller
} }
// Super admins go to Filament superadmin panel // Super admins go to Filament superadmin panel
if ($user && $user->role === 'super_admin') { if ($user && $user->isSuperAdmin()) {
return '/super-admin'; return '/super-admin';
} }

View File

@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
$user = Auth::user(); $user = Auth::user();
// Allow only tenant_admin and super_admin // 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'); return view('admin');
} }

View File

@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
/** @var User|null $user */ /** @var User|null $user */
$user = User::query()->where('email', $email)->first(); $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.'); return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError; use App\Support\ApiError;
use Closure; 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); $violation = $this->limitEvaluator->assessEventCreation($tenant);
if ($violation !== null) { if ($violation !== null) {
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
return $next($request); 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 private function requiresCredits(Request $request): bool
{ {
return $request->isMethod('post') return $request->isMethod('post')

View File

@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
/** @var Tenant|null $tenant */ /** @var Tenant|null $tenant */
$tenant = $user->tenant; $tenant = $user->tenant;
if (! $tenant && $user->role === 'super_admin') { if (! $tenant && $user->isSuperAdmin()) {
$requestedTenantId = $this->resolveRequestedTenantId($request); $requestedTenantId = $this->resolveRequestedTenantId($request);
if ($requestedTenantId !== null) { 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.'); return $this->forbiddenResponse('Tenant context missing for user.');
} }
if ($tenant) { if ($tenant) {
$request->attributes->set('tenant_id', $tenant->id); $request->attributes->set('tenant_id', $tenant->id);
$request->attributes->set('tenant', $tenant); $request->attributes->set('tenant', $tenant);
} elseif ($user->role === 'super_admin') { } elseif ($user->isSuperAdmin()) {
$requestedTenantId = $this->resolveRequestedTenantId($request); $requestedTenantId = $this->resolveRequestedTenantId($request);
if ($requestedTenantId !== null) { if ($requestedTenantId !== null) {
$request->attributes->set('tenant_id', $requestedTenantId); $request->attributes->set('tenant_id', $requestedTenantId);
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
*/ */
protected function allowedRoles(): array protected function allowedRoles(): array
{ {
return ['tenant_admin', 'super_admin', 'admin']; return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
} }
protected function forbiddenRoleMessage(): string protected function forbiddenRoleMessage(): string

View File

@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
{ {
protected function allowedRoles(): array protected function allowedRoles(): array
{ {
return ['tenant_admin', 'super_admin', 'admin', 'member']; return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
} }
protected function forbiddenRoleMessage(): string protected function forbiddenRoleMessage(): string

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError; use App\Support\ApiError;
use Closure; 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); $violation = $this->detectViolation($request, $tenant);
if ($violation !== null) { if ($violation !== null) {
@@ -43,6 +44,24 @@ class PackageMiddleware
return $next($request); 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 private function requiresPackageCheck(Request $request): bool
{ {
return $request->isMethod('post') && ( return $request->isMethod('post') && (

View File

@@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
return '/event-admin/dashboard'; return '/event-admin/dashboard';
} }
if ($user && $user->role === 'super_admin') { if ($user && $user->isSuperAdmin()) {
return '/super-admin'; return '/super-admin';
} }

View File

@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class SuperAdminAuth class SuperAdminAuth
{ {
@@ -21,15 +21,15 @@ class SuperAdminAuth
return $next($request); return $next($request);
} }
if (!Auth::check()) { if (! Auth::check()) {
abort(403, 'Nicht angemeldet.'); abort(403, 'Nicht angemeldet.');
} }
$user = Auth::user(); $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') { if (! $user->isSuperAdmin()) {
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role); abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
} }
return $next($request); return $next($request);

View File

@@ -30,6 +30,7 @@ class EventStoreRequest extends FormRequest
'event_date' => ['required', 'date', 'after_or_equal:today'], 'event_date' => ['required', 'date', 'after_or_equal:today'],
'location' => ['nullable', 'string', 'max:255'], 'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'], 'event_type_id' => ['required', 'exists:event_types,id'],
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'], 'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
'public_url' => ['nullable', 'url', 'max:500'], 'public_url' => ['nullable', 'url', 'max:500'],
'custom_domain' => ['nullable', 'string', 'max:255'], 'custom_domain' => ['nullable', 'string', 'max:255'],

View File

@@ -41,9 +41,9 @@ class GuestPolicySetting extends Model
'per_device_upload_limit' => 50, 'per_device_upload_limit' => 50,
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10), '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_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_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_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
'join_token_ttl_hours' => 168, 'join_token_ttl_hours' => 168,
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48), 'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),

View File

@@ -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. * Retrieve the user by the given credentials.
*/ */
@@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
public function canAccessPanel(Panel $panel): bool 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 false;
} }
return match ($panel->getId()) { return match ($panel->getId()) {
'superadmin' => $this->role === 'super_admin', 'superadmin' => $this->isSuperAdmin(),
'admin' => $this->role === 'tenant_admin', 'admin' => $this->role === 'tenant_admin',
default => false, default => false,
}; };
@@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
public function canAccessTenant(Model $tenant): bool public function canAccessTenant(Model $tenant): bool
{ {
if ($this->role === 'super_admin') { if ($this->isSuperAdmin()) {
return true; return true;
} }
@@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
public function getTenants(Panel $panel): array|Collection public function getTenants(Panel $panel): array|Collection
{ {
if ($this->role === 'super_admin') { if ($this->isSuperAdmin()) {
return Tenant::query()->orderBy('name')->get(); return Tenant::query()->orderBy('name')->get();
} }

View File

@@ -12,12 +12,11 @@ class PurchaseHistoryPolicy
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
public function view(User $user, PurchaseHistory $purchaseHistory): bool public function view(User $user, PurchaseHistory $purchaseHistory): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
} }

View File

@@ -15,7 +15,7 @@ class TenantPolicy
*/ */
public function viewAny(User $user): bool 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 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 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 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 public function suspend(User $user, Tenant $tenant): bool
{ {
return $user->role === 'super_admin'; return $user->isSuperAdmin();
} }
} }

View File

@@ -155,7 +155,11 @@ class AppServiceProvider extends ServiceProvider
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown')); $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) { RateLimiter::for('tenant-auth', function (Request $request) {

View File

@@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider
}); });
Gate::before(function (User $user): ?bool { Gate::before(function (User $user): ?bool {
return $user->role === 'super_admin' ? true : null; return $user->isSuperAdmin() ? true : null;
}); });
} }
} }

View File

@@ -83,7 +83,7 @@ class SuperAdminAuditLogger
private function shouldLog(?User $actor): bool private function shouldLog(?User $actor): bool
{ {
if (! $actor || $actor->role !== 'super_admin') { if (! $actor || ! $actor->isSuperAdmin()) {
return false; return false;
} }

View File

@@ -24,15 +24,15 @@ class TenantAuth
} }
$user = $request->user(); $user = $request->user();
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) { if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) { if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
return $user; return $user;
} }
} }
$user = User::query() $user = User::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->whereIn('role', ['tenant_admin', 'admin', 'member']) ->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
->orderByDesc('email_verified_at') ->orderByDesc('email_verified_at')
->orderBy('id') ->orderBy('id')
->first(); ->first();

View File

@@ -4,9 +4,9 @@ return [
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10), 'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5), '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), '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), 'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
]; ];

View File

@@ -2,9 +2,11 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Models\User; use Illuminate\Support\Str;
class SuperAdminSeeder extends Seeder class SuperAdminSeeder extends Seeder
{ {
@@ -12,12 +14,49 @@ class SuperAdminSeeder extends Seeder
{ {
$email = env('ADMIN_EMAIL', 'admin@example.com'); $email = env('ADMIN_EMAIL', 'admin@example.com');
$password = env('ADMIN_PASSWORD', 'ChangeMe123!'); $password = env('ADMIN_PASSWORD', 'ChangeMe123!');
User::updateOrCreate(['email'=>$email], [ $user = User::updateOrCreate(['email' => $email], [
'first_name' => 'Super', 'first_name' => 'Super',
'last_name' => 'Admin', 'last_name' => 'Admin',
'password' => Hash::make($password), 'password' => Hash::make($password),
'role' => 'super_admin', '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();
}
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -14,13 +14,11 @@ import { getEventAnalytics, EventAnalytics } from '../api';
import { ApiError } from '../lib/apiError'; import { ApiError } from '../lib/apiError';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
export default function MobileEventAnalyticsPage() { export default function MobileEventAnalyticsPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation('management'); const { t, i18n } = useTranslation('management');
const navigate = useNavigate(); const navigate = useNavigate();
const { activeEvent } = useEventContext();
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
const dateLocale = i18n.language.startsWith('de') ? de : enGB; const dateLocale = i18n.language.startsWith('de') ? de : enGB;
@@ -106,7 +104,6 @@ export default function MobileEventAnalyticsPage() {
return ( return (
<MobileShell <MobileShell
title={t('analytics.title', 'Analytics')} title={t('analytics.title', 'Analytics')}
subtitle={activeEvent?.name as string}
activeTab="home" activeTab="home"
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
> >

View File

@@ -9,7 +9,7 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet'; 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 { resolveEventSlugAfterUpdate } from './eventFormNavigation';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
@@ -18,6 +18,7 @@ import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { withAlpha } from './components/colors'; import { withAlpha } from './components/colors';
import { useAuth } from '../auth/context';
type FormState = { type FormState = {
name: string; name: string;
@@ -28,6 +29,7 @@ type FormState = {
published: boolean; published: boolean;
autoApproveUploads: boolean; autoApproveUploads: boolean;
tasksEnabled: boolean; tasksEnabled: boolean;
packageId: number | null;
}; };
export default function MobileEventFormPage() { export default function MobileEventFormPage() {
@@ -36,7 +38,9 @@ export default function MobileEventFormPage() {
const isEdit = Boolean(slug); const isEdit = Boolean(slug);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']); const { t } = useTranslation(['management', 'common']);
const { user } = useAuth();
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme(); const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
const [form, setForm] = React.useState<FormState>({ const [form, setForm] = React.useState<FormState>({
name: '', name: '',
@@ -47,9 +51,12 @@ export default function MobileEventFormPage() {
published: false, published: false,
autoApproveUploads: true, autoApproveUploads: true,
tasksEnabled: true, tasksEnabled: true,
packageId: null,
}); });
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]); const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false); const [typesLoading, setTypesLoading] = React.useState(false);
const [packages, setPackages] = React.useState<Package[]>([]);
const [packagesLoading, setPackagesLoading] = React.useState(false);
const [loading, setLoading] = React.useState(isEdit); const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false); const [consentOpen, setConsentOpen] = React.useState(false);
@@ -76,6 +83,7 @@ export default function MobileEventFormPage() {
tasksEnabled: tasksEnabled:
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' && (data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
(data.engagement_mode as string | undefined) !== 'photo_only', (data.engagement_mode as string | undefined) !== 'photo_only',
packageId: null,
}); });
setError(null); setError(null);
} catch (err) { } 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() { async function handleSubmit() {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -131,6 +164,7 @@ export default function MobileEventFormPage() {
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined, event_date: form.date || undefined,
status: form.published ? 'published' : 'draft', status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -153,6 +187,7 @@ export default function MobileEventFormPage() {
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined, event_date: form.date || undefined,
status: form.published ? 'published' : 'draft', status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -223,6 +258,31 @@ export default function MobileEventFormPage() {
/> />
</MobileField> </MobileField>
{isSuperAdmin && !isEdit ? (
<MobileField label={t('eventForm.fields.package.label', 'Package')}>
{packagesLoading ? (
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.loading', 'Loading packages…')}</Text>
) : packages.length === 0 ? (
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.empty', 'No packages available yet.')}</Text>
) : (
<MobileSelect
value={form.packageId ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, packageId: Number(e.target.value) }))}
>
<option value="">{t('eventForm.fields.package.placeholder', 'Select package')}</option>
{packages.map((pkg) => (
<option key={pkg.id} value={pkg.id}>
{pkg.name || `#${pkg.id}`}
</option>
))}
</MobileSelect>
)}
<Text fontSize="$xs" color={muted}>
{t('eventForm.fields.package.help', 'This controls the events premium limits.')}
</Text>
</MobileField>
) : null}
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}> <MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<NativeDateTimeInput <NativeDateTimeInput

View File

@@ -12,7 +12,7 @@ import { MobileField, MobileInput, MobileSelect } from './components/FormControl
import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api'; import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, resolveEventDisplayName } from '../lib/events'; import { resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
@@ -262,8 +262,7 @@ export default function MobileEventLiveShowSettingsPage() {
return ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={event ? resolveEventDisplayName(event) : t('liveShowSettings.title', 'Live Show settings')} title={t('liveShowSettings.title', 'Live Show settings')}
subtitle={event?.event_date ? formatEventDate(event.event_date, locale) ?? undefined : undefined}
onBack={back} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>

View File

@@ -19,7 +19,7 @@ import {
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, resolveEventDisplayName } from '../lib/events'; import { formatEventDate } from '../lib/events';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
@@ -146,9 +146,7 @@ export default function MobileEventPhotoboothPage() {
: t('photobooth.credentials.heading', 'FTP (Classic)'); : t('photobooth.credentials.heading', 'FTP (Classic)');
const isActive = Boolean(status?.enabled); const isActive = Boolean(status?.enabled);
const title = event ? resolveEventDisplayName(event) : t('management.header.appName', 'Event Admin'); const title = t('photobooth.title', 'Photobooth');
const subtitle =
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
const handleToggle = (checked: boolean) => { const handleToggle = (checked: boolean) => {
if (!slug || updating) return; if (!slug || updating) return;
@@ -163,7 +161,6 @@ export default function MobileEventPhotoboothPage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={title} title={title}
subtitle={subtitle ?? undefined}
onBack={back} onBack={back}
headerActions={ headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}> <HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>

View File

@@ -152,7 +152,6 @@ export default function MobileEventRecapPage() {
<MobileShell <MobileShell
activeTab="home" activeTab="home"
title={t('events.recap.title', 'Event Recap')} title={t('events.recap.title', 'Event Recap')}
subtitle={resolveName(event.name)}
onBack={back} onBack={back}
> >
<YStack space="$4"> <YStack space="$4">

View File

@@ -691,6 +691,7 @@ export default function MobileNotificationsPage() {
} }
}} }}
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')} title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
snapPoints={[94]}
footer={ footer={
selectedNotification && !selectedNotification.is_read ? ( selectedNotification && !selectedNotification.is_read ? (
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} /> <CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
@@ -705,7 +706,7 @@ export default function MobileNotificationsPage() {
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{selectedNotification.body} {selectedNotification.body}
</Text> </Text>
<XStack space="$2"> <XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}> <PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
{selectedNotification.scope} {selectedNotification.scope}
</PillBadge> </PillBadge>

View File

@@ -19,6 +19,7 @@ vi.mock('../../api', () => ({
getEvent: vi.fn(), getEvent: vi.fn(),
updateEvent: vi.fn(), updateEvent: vi.fn(),
getEventTypes: vi.fn().mockResolvedValue([]), getEventTypes: vi.fn().mockResolvedValue([]),
getPackages: vi.fn().mockResolvedValue([]),
trackOnboarding: vi.fn(), trackOnboarding: vi.fn(),
})); }));
@@ -81,6 +82,10 @@ vi.mock('../theme', () => ({
}), }),
})); }));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ user: { role: 'tenant_admin' } }),
}));
import { getEventTypes } from '../../api'; import { getEventTypes } from '../../api';
import MobileEventFormPage from '../EventFormPage'; import MobileEventFormPage from '../EventFormPage';

View File

@@ -1,6 +1,6 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; 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 { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
@@ -9,11 +9,10 @@ import { useEventContext } from '../../context/EventContext';
import { BottomNav, NavKey } from './BottomNav'; import { BottomNav, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav'; import { useMobileNav } from '../hooks/useMobileNav';
import { adminPath } from '../../constants'; import { adminPath } from '../../constants';
import { MobileSheet } from './Sheet'; import { MobileCard, CTAButton } from './Primitives';
import { MobileCard, PillBadge, CTAButton } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge'; import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { useOnlineStatus } from '../hooks/useOnlineStatus'; import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events'; import { resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api'; import { TenantEvent, getEvents } from '../../api';
import { withAlpha } from './colors'; import { withAlpha } from './colors';
import { setTabHistory } from '../lib/tabHistory'; import { setTabHistory } from '../lib/tabHistory';
@@ -31,11 +30,11 @@ type MobileShellProps = {
}; };
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: 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 { go } = useMobileNav(activeEvent?.slug, activeTab);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { t, i18n } = useTranslation('mobile'); const { t } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge(); const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus(); const online = useOnlineStatus();
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme(); 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 textColor = text;
const mutedText = muted; const mutedText = muted;
const headerSurface = withAlpha(surfaceColor, 0.94); const headerSurface = withAlpha(surfaceColor, 0.94);
const [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]); const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false); const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false); const [attemptedFetch, setAttemptedFetch] = React.useState(false);
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0); 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 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); const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
React.useEffect(() => { React.useEffect(() => {
@@ -74,16 +70,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
.finally(() => setLoadingEvents(false)); .finally(() => setLoadingEvents(false));
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]); }, [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(() => { React.useEffect(() => {
const path = `${location.pathname}${location.search}${location.hash}`; const path = `${location.pathname}${location.search}${location.hash}`;
@@ -114,44 +100,26 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
}; };
}, [refreshQueuedActions]); }, [refreshQueuedActions]);
const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin')); React.useEffect(() => {
const subtitleText = if (typeof window === 'undefined' || !window.matchMedia) {
subtitle ?? return;
(effectiveActive?.event_date }
? formatEventDate(effectiveActive.event_date, locale) ?? '' const query = window.matchMedia('(max-width: 520px)');
: effectiveHasEvents const handleChange = (event: MediaQueryListEvent) => {
? t('header.selectEvent', 'Select an event to continue') setIsCompactHeader(event.matches);
: t('header.empty', 'Create your first event to get started')); };
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 showQr = Boolean(effectiveActive?.slug);
const headerBackButton = onBack ? (
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={borderColor}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
shadowColor={shadow}
shadowOpacity={0.06}
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
width="100%"
maxWidth={800}
position="sticky"
top={0}
zIndex={60}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{onBack ? (
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}> <HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5"> <XStack alignItems="center" space="$1.5">
<ChevronLeft size={28} color={primary} strokeWidth={2.5} /> <ChevronLeft size={28} color={primary} strokeWidth={2.5} />
@@ -159,27 +127,22 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</HeaderActionButton> </HeaderActionButton>
) : ( ) : (
<XStack width={28} /> <XStack width={28} />
)} );
const headerTitle = (
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end"> <XStack alignItems="center" space="$1" flex={1} minWidth={0} justifyContent="flex-end">
<XStack alignItems="center" space="$1" maxWidth="55%"> <YStack alignItems="flex-end" maxWidth="100%">
<Pressable
disabled={!showEventSwitcher}
onPress={() => setPickerOpen(true)}
style={{ alignItems: 'flex-end' }}
>
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}> <Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{eventTitle} {pageTitle}
</Text> </Text>
{subtitleText ? ( {subtitleText ? (
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body"> <Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
{subtitleText} {subtitleText}
</Text> </Text>
) : null} ) : null}
</Pressable> </YStack>
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
</XStack> </XStack>
);
const headerActionsRow = (
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<HeaderActionButton <HeaderActionButton
onPress={() => navigate(adminPath('/mobile/notifications'))} onPress={() => navigate(adminPath('/mobile/notifications'))}
@@ -221,25 +184,66 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
ariaLabel={t('header.quickQr', 'Quick QR')} ariaLabel={t('header.quickQr', 'Quick QR')}
> >
<XStack <XStack
width={34}
height={34} height={34}
paddingHorizontal="$3"
borderRadius={12} borderRadius={12}
backgroundColor={primary} backgroundColor={primary}
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
space="$1.5"
> >
<QrCode size={16} color="white" /> <QrCode size={16} color="white" />
<Text fontSize="$xs" fontWeight="800" color="white">
{t('header.quickQr', 'Quick QR')}
</Text>
</XStack> </XStack>
</HeaderActionButton> </HeaderActionButton>
) : null} ) : null}
{headerActions ?? null} {headerActions ?? null}
</XStack> </XStack>
);
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={borderColor}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
shadowColor={shadow}
shadowOpacity={0.06}
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
width="100%"
maxWidth={800}
position="sticky"
top={0}
zIndex={60}
style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
{isCompactHeader ? (
<YStack space="$2">
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{headerBackButton}
<XStack flex={1} minWidth={0} justifyContent="flex-end">
{headerTitle}
</XStack> </XStack>
</XStack> </XStack>
<XStack alignItems="center" justifyContent="flex-end">
{headerActionsRow}
</XStack>
</YStack>
) : (
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{headerBackButton}
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end" minWidth={0}>
{headerTitle}
{headerActionsRow}
</XStack>
</XStack>
)}
</YStack> </YStack>
<YStack <YStack
@@ -290,75 +294,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
<BottomNav active={activeTab} onNavigate={go} /> <BottomNav active={activeTab} onNavigate={go} />
<MobileSheet
open={pickerOpen}
onClose={() => setPickerOpen(false)}
title={t('header.eventSwitcher', 'Choose an event')}
footer={null}
bottomOffsetPx={110}
>
<YStack space="$2">
{effectiveEvents.length === 0 ? (
<MobileCard alignItems="flex-start" space="$2">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{t('header.noEventsTitle', 'Create your first event')}
</Text>
<Text fontSize="$xs" color={mutedText}>
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
</Text>
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
<XStack alignItems="center" space="$2">
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('header.createEvent', 'Create event')}
</Text>
</XStack>
</Pressable>
</MobileCard>
) : (
effectiveEvents.map((event) => (
<Pressable
key={event.slug}
onPress={() => {
const targetSlug = event.slug ?? null;
selectEvent(targetSlug);
setPickerOpen(false);
if (targetSlug) {
navigate(adminPath(`/mobile/events/${targetSlug}`));
}
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color={mutedText}>
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
</Text>
</YStack>
<PillBadge tone={event.slug === activeEvent?.slug ? 'success' : 'muted'}>
{event.slug === activeEvent?.slug
? t('header.active', 'Active')
: (event.status ?? '—')}
</PillBadge>
</XStack>
</Pressable>
))
)}
{activeEvent ? (
<Pressable
onPress={() => {
selectEvent(null);
setPickerOpen(false);
}}
>
<Text fontSize="$xs" color={mutedText} textAlign="center">
{t('header.clearSelection', 'Clear selection')}
</Text>
</Pressable>
) : null}
</YStack>
</MobileSheet>
</YStack> </YStack>
); );
} }

View File

@@ -50,7 +50,7 @@ describe('MobileSheet', () => {
const onClose = vi.fn(); const onClose = vi.fn();
render( render(
<MobileSheet open title="Test Sheet" onClose={onClose}> <MobileSheet open title="Test Sheet" onClose={onClose} snapPoints={[94]} contentSpacing="$2" padding="$3" paddingBottom="$6">
<div>Body</div> <div>Body</div>
</MobileSheet>, </MobileSheet>,
); );

View File

@@ -12,11 +12,26 @@ type SheetProps = {
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
footer?: 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. */ /** Optional bottom offset so content sits above the bottom nav/safe-area. */
bottomOffsetPx?: number; 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 { t } = useTranslation('mobile');
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme(); const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`; 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(); onClose();
} }
}} }}
snapPoints={[82]} snapPoints={snapPoints}
snapPointsMode="percent" snapPointsMode="percent"
dismissOnOverlayPress dismissOnOverlayPress
dismissOnSnapToBottom dismissOnSnapToBottom
@@ -48,8 +63,8 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
borderTopLeftRadius: 24, borderTopLeftRadius: 24,
borderTopRightRadius: 24, borderTopRightRadius: 24,
backgroundColor: surface, backgroundColor: surface,
padding: '$4', padding,
paddingBottom: '$7', paddingBottom,
shadowColor: shadow, shadowColor: shadow,
shadowOpacity: 0.12, shadowOpacity: 0.12,
shadowRadius: 18, shadowRadius: 18,
@@ -62,7 +77,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)} {...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
> >
<YStack space="$3"> <YStack space={contentSpacing}>
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Text fontSize="$md" fontWeight="800" color={textStrong}>
{title} {title}

View File

@@ -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', [HelpController::class, 'index'])->name('help.index');
Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show'); Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show');
Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show'); Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show');

View File

@@ -11,6 +11,7 @@ use App\Models\TenantPackage;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Tests\Feature\Tenant\TenantTestCase; use Tests\Feature\Tenant\TenantTestCase;
@@ -77,6 +78,55 @@ class EventControllerTest extends TenantTestCase
->assertJsonPath('error.code', 'event_limit_missing'); ->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 public function test_create_event_requires_waiver_for_endcustomer_package(): void
{ {
$tenant = $this->tenant; $tenant = $this->tenant;