- Reworked the tenant admin login page

- Updated the User model to implement Filament’s tenancy contracts
- Seeded a ready-to-use demo tenant (user, tenant, active package, purchase)
- Introduced a branded, translated 403 error page to replace the generic forbidden message for unauthorised admin hits
- Removed the public “Register” links from the marketing header
- hardened join event logic and improved error handling in the guest pwa.
This commit is contained in:
Codex Agent
2025-10-13 12:50:46 +02:00
parent 9394c3171e
commit 64a5411fb9
69 changed files with 5447 additions and 588 deletions

View File

@@ -2,91 +2,60 @@
namespace App\Filament\Pages\Auth;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\SimplePage;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Schemas\Components\Component;
class Login extends SimplePage implements HasForms
class Login extends BaseLogin
{
use InteractsWithForms;
protected string $view = 'filament.pages.auth.login';
protected static ?string $title = 'Tenant Login';
public function getFormSchema(): array
public function getTitle(): string
{
return [
TextInput::make('data.username_or_email')
->label('Username or Email')
->required()
->autofocus(),
TextInput::make('data.password')
->password()
->required()
->extraAttributes(['tabindex' => 2]),
Checkbox::make('data.remember')
->label('Remember me'),
];
return __('auth.login.title') ?: parent::getTitle();
}
public function submit(): void
public function getHeading(): string
{
$data = $this->form->getState();
return __('auth.login.title') ?: parent::getHeading();
}
$credentials = $this->getCredentialsFromFormData($data);
protected function getEmailFormComponent(): Component
{
$component = parent::getEmailFormComponent();
if (! Auth::attempt($credentials, $data['remember'] ?? false)) {
throw ValidationException::withMessages([
'data.username_or_email' => __('auth.failed'),
]);
}
return $component
->label(__('auth.login.username_or_email') ?: $component->getLabel());
}
$user = Auth::user();
protected function getPasswordFormComponent(): Component
{
$component = parent::getPasswordFormComponent();
if (! $user->email_verified_at) {
Auth::logout();
return $component
->label(__('auth.login.password') ?: $component->getLabel());
}
throw ValidationException::withMessages([
'data.username_or_email' => 'Your email address is not verified. Please check your email for a verification link.',
]);
}
protected function getRememberFormComponent(): Component
{
$component = parent::getRememberFormComponent();
if (! $user->tenant) {
Auth::logout();
throw ValidationException::withMessages([
'data.username_or_email' => 'No tenant associated with your account. Contact support.',
]);
}
session()->regenerate();
$this->redirect($this->getRedirectUrl());
return $component
->label(__('auth.login.remember_me') ?: $component->getLabel());
}
protected function getCredentialsFromFormData(array $data): array
{
$usernameOrEmail = $data['username_or_email'];
$password = $data['password'];
$identifier = $data['email'] ?? '';
$password = $data['password'] ?? '';
$credentials = ['password' => $password];
if (filter_var($usernameOrEmail, FILTER_VALIDATE_EMAIL)) {
$credentials['email'] = $usernameOrEmail;
} else {
$credentials['username'] = $usernameOrEmail;
if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
return [
'email' => $identifier,
'password' => $password,
];
}
return $credentials;
return [
'username' => $identifier,
'password' => $password,
];
}
public function hasLogo(): bool
{
return false;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources;
use App\Filament\Resources\EventResource\Pages;
use App\Support\JoinTokenLayoutRegistry;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\EventType;
@@ -102,8 +103,20 @@ class EventResource extends Resource
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
Tables\Columns\TextColumn::make('join')->label(__('admin.events.table.join'))
->getStateUsing(fn($record) => url("/e/{$record->slug}"))
Tables\Columns\TextColumn::make('primary_join_token')
->label(__('admin.events.table.join'))
->getStateUsing(function ($record) {
$token = $record->joinTokens()->orderByDesc('created_at')->first();
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
})
->description(function ($record) {
$total = $record->joinTokens()->count();
return $total > 0
? __('admin.events.table.join_tokens_total', ['count' => $total])
: __('admin.events.table.join_tokens_missing');
})
->copyable()
->copyMessage(__('admin.events.messages.join_link_copied')),
Tables\Columns\TextColumn::make('created_at')->since(),
@@ -115,14 +128,50 @@ class EventResource extends Resource
->label(__('admin.events.actions.toggle_active'))
->icon('heroicon-o-power')
->action(fn($record) => $record->update(['is_active' => !$record->is_active])),
Actions\Action::make('join_link')
Actions\Action::make('join_tokens')
->label(__('admin.events.actions.join_link_qr'))
->icon('heroicon-o-qr-code')
->modalHeading(__('admin.events.modal.join_link_heading'))
->modalSubmitActionLabel(__('admin.common.close'))
->modalContent(fn($record) => view('filament.events.join-link', [
'link' => url("/e/{$record->slug}"),
])),
->modalWidth('xl')
->modalContent(function ($record) {
$tokens = $record->joinTokens()
->orderByDesc('created_at')
->get()
->map(function ($token) use ($record) {
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
return [
'id' => $token->id,
'label' => $token->label,
'token' => $token->token,
'url' => url('/e/'.$token->token),
'usage_limit' => $token->usage_limit,
'usage_count' => $token->usage_count,
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('tenant.events.join-tokens.layouts.index', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
]),
];
});
return view('filament.events.join-link', [
'event' => $record,
'tokens' => $tokens,
]);
}),
])
->bulkActions([
Actions\DeleteBulkAction::make(),

View File

@@ -6,11 +6,15 @@ use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use App\Support\ImageHelper;
use App\Services\EventJoinTokenService;
use App\Models\EventJoinToken;
use Illuminate\Http\JsonResponse;
class EventPublicController extends BaseController
{
@@ -19,36 +23,122 @@ class EventPublicController extends BaseController
}
/**
* @return array{0: object|null, 1: \App\Models\EventJoinToken|null}
* @return JsonResponse|array{0: object, 1: EventJoinToken}
*/
private function resolvePublishedEvent(string $identifier, array $columns = ['id']): array
private function resolvePublishedEvent(Request $request, string $token, array $columns = ['id']): JsonResponse|array
{
$event = DB::table('events')
->where('slug', $identifier)
->where('status', 'published')
->first($columns);
$rateLimiterKey = sprintf('event:token:%s:%s', $token, $request->ip());
if ($event) {
return [$event, null];
}
$joinToken = $this->joinTokenService->findToken($token, true);
$joinToken = $this->joinTokenService->findActiveToken($identifier);
if (! $joinToken) {
return [null, null];
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
'token' => Str::limit($token, 12),
]);
}
if ($joinToken->revoked_at !== null) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_revoked', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
]);
}
if ($joinToken->expires_at !== null) {
$expiresAt = CarbonImmutable::parse($joinToken->expires_at);
if ($expiresAt->isPast()) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
'expired_at' => $expiresAt->toAtomString(),
]);
}
}
if ($joinToken->usage_limit !== null && $joinToken->usage_count >= $joinToken->usage_limit) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
'usage_count' => $joinToken->usage_count,
'usage_limit' => $joinToken->usage_limit,
]);
}
$columns = array_unique(array_merge($columns, ['status']));
$event = DB::table('events')
->where('id', $joinToken->event_id)
->where('status', 'published')
->first($columns);
if (! $event) {
return [null, null];
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
'token' => Str::limit($token, 12),
'reason' => 'event_missing',
]);
}
if (($event->status ?? null) !== 'published') {
Log::notice('Join token event not public', [
'token' => Str::limit($token, 12),
'event_id' => $event->id ?? null,
'ip' => $request->ip(),
]);
return response()->json([
'error' => [
'code' => 'event_not_public',
'message' => 'This event is not publicly accessible.',
],
], Response::HTTP_FORBIDDEN);
}
RateLimiter::clear($rateLimiterKey);
if (isset($event->status)) {
unset($event->status);
}
return [$event, $joinToken];
}
private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse
{
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
Log::warning('Join token rate limit exceeded', array_merge([
'ip' => $request->ip(),
], $context));
return response()->json([
'error' => [
'code' => 'token_rate_limited',
'message' => 'Too many invalid join token attempts. Try again later.',
],
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($rateLimiterKey, 300);
Log::notice('Join token access denied', array_merge([
'code' => $code,
'ip' => $request->ip(),
], $context));
return response()->json([
'error' => [
'code' => $code,
'message' => $this->tokenErrorMessage($code),
],
], $status);
}
private function tokenErrorMessage(string $code): string
{
return match ($code) {
'invalid_token' => 'The provided join token is invalid.',
'token_expired' => 'The join token has expired.',
'token_revoked' => 'The join token has been revoked.',
default => 'Access denied.',
};
}
private function getLocalized($value, $locale, $default = '') {
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -81,9 +171,9 @@ class EventPublicController extends BaseController
return $path; // fallback as-is
}
public function event(string $identifier)
public function event(Request $request, string $token)
{
[$event, $joinToken] = $this->resolvePublishedEvent($identifier, [
$result = $this->resolvePublishedEvent($request, $token, [
'id',
'slug',
'name',
@@ -92,11 +182,14 @@ class EventPublicController extends BaseController
'updated_at',
'event_type_id',
]);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
if ($result instanceof JsonResponse) {
return $result;
}
$locale = request()->query('locale', $event->default_locale ?? 'de');
[$event, $joinToken] = $result;
$locale = $request->query('locale', $event->default_locale ?? 'de');
$nameData = json_decode($event->name, true);
$localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name;
@@ -133,13 +226,15 @@ class EventPublicController extends BaseController
])->header('Cache-Control', 'no-store');
}
public function stats(string $identifier)
public function stats(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
// Approximate online guests as distinct recent uploaders in last 10 minutes.
@@ -162,7 +257,7 @@ class EventPublicController extends BaseController
];
$etag = sha1(json_encode($payload));
$reqEtag = request()->headers->get('If-None-Match');
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
@@ -172,14 +267,17 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function emotions(string $identifier)
public function emotions(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$locale = $request->query('locale', 'de');
$rows = DB::table('emotions')
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
@@ -195,8 +293,7 @@ class EventPublicController extends BaseController
->orderBy('emotions.sort_order')
->get();
$payload = $rows->map(function ($r) {
$locale = request()->query('locale', 'de');
$payload = $rows->map(function ($r) use ($locale) {
$nameData = json_decode($r->name, true);
$name = $nameData[$locale] ?? $nameData['de'] ?? $r->name;
@@ -213,7 +310,7 @@ class EventPublicController extends BaseController
});
$etag = sha1($payload->toJson());
$reqEtag = request()->headers->get('If-None-Match');
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
@@ -223,13 +320,15 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function tasks(string $identifier, Request $request)
public function tasks(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$query = DB::table('tasks')
@@ -249,11 +348,11 @@ class EventPublicController extends BaseController
->limit(20);
$rows = $query->get();
$locale = $request->query('locale', 'de');
$payload = $rows->map(function ($r) {
$payload = $rows->map(function ($r) use ($locale) {
// Handle JSON fields for multilingual content
$getLocalized = function ($field, $default = '') use ($r) {
$locale = request()->query('locale', 'de');
$getLocalized = function ($field, $default = '') use ($r, $locale) {
$value = $r->$field;
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -267,7 +366,6 @@ class EventPublicController extends BaseController
if ($r->emotion_id) {
$emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']);
if ($emotionRow && isset($emotionRow->name)) {
$locale = request()->query('locale', 'de');
$value = $emotionRow->name;
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -299,7 +397,7 @@ class EventPublicController extends BaseController
}
$etag = sha1($payload->toJson());
$reqEtag = request()->headers->get('If-None-Match');
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
@@ -309,12 +407,15 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function photos(Request $request, string $identifier)
public function photos(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$deviceId = (string) $request->header('X-Device-Id', 'anon');
@@ -345,7 +446,7 @@ class EventPublicController extends BaseController
if ($since) {
$query->where('photos.created_at', '>', $since);
}
$locale = request()->query('locale', 'de');
$locale = $request->query('locale', 'de');
$rows = $query->get()->map(function ($r) use ($locale) {
$r->file_path = $this->toPublicUrl((string)($r->file_path ?? ''));
@@ -364,7 +465,7 @@ class EventPublicController extends BaseController
'latest_photo_at' => $latestPhotoAt,
];
$etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt]));
$reqEtag = request()->headers->get('If-None-Match');
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
@@ -455,12 +556,15 @@ class EventPublicController extends BaseController
return response()->json(['liked' => true, 'likes_count' => $count]);
}
public function upload(Request $request, string $identifier)
public function upload(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$deviceId = (string) $request->header('X-Device-Id', 'anon');
@@ -556,11 +660,13 @@ class EventPublicController extends BaseController
}
public function achievements(Request $request, string $identifier)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $identifier, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$locale = $request->query('locale', 'de');

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Support\JoinTokenLayoutRegistry;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class EventJoinTokenLayoutController extends Controller
{
public function index(Request $request, Event $event, EventJoinToken $joinToken)
{
$this->ensureBelongsToEvent($event, $joinToken);
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $event,
'joinToken' => $joinToken,
'layout' => $layoutId,
'format' => $format,
]);
});
return response()->json([
'data' => $layouts,
]);
}
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
{
$this->ensureBelongsToEvent($event, $joinToken);
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
if (! $layoutConfig) {
abort(404, 'Layout nicht gefunden.');
}
if (! in_array($format, ['pdf', 'svg'], true)) {
abort(404, 'Unbekanntes Exportformat.');
}
$tokenUrl = url('/e/'.$joinToken->token);
$qrPngDataUri = 'data:image/png;base64,'.base64_encode(
QrCode::format('png')
->margin(0)
->size($layoutConfig['qr']['size_px'])
->generate($tokenUrl)
);
$backgroundStyle = $this->buildBackgroundStyle($layoutConfig);
$eventName = $this->resolveEventName($event);
$viewData = [
'layout' => $layoutConfig,
'event' => $event,
'eventName' => $eventName,
'token' => $joinToken,
'tokenUrl' => $tokenUrl,
'qrPngDataUri' => $qrPngDataUri,
'backgroundStyle' => $backgroundStyle,
];
$filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format);
if ($format === 'svg') {
$svg = view('layouts.join-token.svg', $viewData)->render();
return response($svg)
->header('Content-Type', 'image/svg+xml')
->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
}
$html = view('layouts.join-token.pdf', $viewData)->render();
$options = new Options();
$options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Helvetica');
$dompdf = new Dompdf($options);
$dompdf->setPaper(strtoupper($layoutConfig['paper']), $layoutConfig['orientation'] === 'landscape' ? 'landscape' : 'portrait');
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->render();
return response($dompdf->output())
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
}
private function ensureBelongsToEvent(Event $event, EventJoinToken $joinToken): void
{
if ($joinToken->event_id !== $event->id) {
abort(404);
}
}
private function resolveEventName(Event $event): string
{
$name = $event->name;
if (is_array($name)) {
$locale = $event->default_locale ?? 'de';
return $name[$locale] ?? $name['de'] ?? reset($name) ?: 'Event';
}
return is_string($name) && $name !== '' ? $name : 'Event';
}
private function buildBackgroundStyle(array $layout): string
{
$gradient = $layout['background_gradient'] ?? null;
if (is_array($gradient) && ! empty($gradient['stops'])) {
$angle = $gradient['angle'] ?? 180;
$stops = implode(',', $gradient['stops']);
return sprintf('linear-gradient(%ddeg,%s)', $angle, $stops);
}
return $layout['background'] ?? '#FFFFFF';
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Http\Resources\Tenant;
use App\Models\Event;
use App\Support\JoinTokenLayoutRegistry;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -12,6 +14,28 @@ class EventJoinTokenResource extends JsonResource
*/
public function toArray($request): array
{
/** @var Event|null $eventFromRoute */
$eventFromRoute = $request->route('event');
$eventContext = $eventFromRoute instanceof Event ? $eventFromRoute : ($this->resource->event ?? null);
$layouts = $eventContext
? JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($eventContext) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $eventContext,
'joinToken' => $this->resource,
'layout' => $layoutId,
'format' => $format,
]);
})
: [];
$layoutsUrl = $eventContext
? route('tenant.events.join-tokens.layouts.index', [
'event' => $eventContext,
'joinToken' => $this->resource,
])
: null;
return [
'id' => $this->id,
'label' => $this->label,
@@ -24,6 +48,8 @@ class EventJoinTokenResource extends JsonResource
'is_active' => $this->isActive(),
'created_at' => optional($this->created_at)->toIso8601String(),
'metadata' => $this->metadata ?? new \stdClass(),
'layouts_url' => $layoutsUrl,
'layouts' => $layouts,
];
}
}

View File

@@ -8,10 +8,16 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Laravel\Sanctum\HasApiTokens;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasTenants as FilamentHasTenants;
use Filament\Panel;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Filament\Models\Contracts\HasName;
class User extends Authenticatable implements MustVerifyEmail, HasName
class User extends Authenticatable implements MustVerifyEmail, HasName, FilamentUser, FilamentHasTenants
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
@@ -99,8 +105,43 @@ class User extends Authenticatable implements MustVerifyEmail, HasName
return $this->username ?? $this->email ?? 'Unnamed User';
}
public function tenant(): HasOne
public function tenant(): BelongsTo
{
return $this->hasOne(Tenant::class);
return $this->belongsTo(Tenant::class);
}
public function canAccessPanel(Panel $panel): bool
{
if (! $this->email_verified_at && $this->role !== 'super_admin') {
return false;
}
return in_array($this->role, ['tenant_admin', 'super_admin'], true);
}
public function canAccessTenant(Model $tenant): bool
{
if ($this->role === 'super_admin') {
return true;
}
$ownedTenant = $this->tenant;
if (! $ownedTenant) {
return false;
}
return (int) $tenant->getKey() === (int) $ownedTenant->getKey();
}
public function getTenants(Panel $panel): array | Collection
{
if ($this->role === 'super_admin') {
return Tenant::query()->orderBy('name')->get();
}
$tenant = $this->tenant;
return $tenant ? collect([$tenant]) : collect();
}
}

View File

@@ -112,6 +112,9 @@ class CheckoutAssignmentService
protected function ensureTenant(User $user, CheckoutSession $session): ?Tenant
{
if ($user->tenant) {
if (! $user->tenant_id) {
$user->forceFill(['tenant_id' => $user->tenant->getKey()])->save();
}
return $user->tenant;
}
@@ -130,6 +133,10 @@ class CheckoutAssignmentService
],
]);
if ($user->tenant_id !== $tenant->id) {
$user->forceFill(['tenant_id' => $tenant->id])->save();
}
event(new Registered($user));
return $tenant;

View File

@@ -58,22 +58,29 @@ class EventJoinTokenService
$joinToken->increment('usage_count');
}
public function findActiveToken(string $token): ?EventJoinToken
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
{
return EventJoinToken::query()
->where('token', $token)
->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($query) {
$query->whereNull('usage_limit')
->orWhereColumn('usage_limit', '>', 'usage_count');
->when(! $includeInactive, function ($query) {
$query->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($query) {
$query->whereNull('usage_limit')
->orWhereColumn('usage_limit', '>', 'usage_count');
});
})
->first();
}
public function findActiveToken(string $token): ?EventJoinToken
{
return $this->findToken($token);
}
protected function generateUniqueToken(int $length = 48): string
{
do {

View File

@@ -0,0 +1,197 @@
<?php
namespace App\Support;
class JoinTokenLayoutRegistry
{
/**
* Layout definitions for printable invite cards.
*
* @var array<string, array>
*/
private const LAYOUTS = [
'modern-poster' => [
'id' => 'modern-poster',
'name' => 'Modern Poster',
'subtitle' => 'Große, auffällige Fläche perfekt für den Eingangsbereich.',
'description' => 'Helle Posteroptik mit diagonalem Farbband und deutlicher Call-to-Action.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#F8FAFC',
'text' => '#0F172A',
'accent' => '#6366F1',
'secondary' => '#CBD5F5',
'badge' => '#0EA5E9',
'qr' => ['size_px' => 340],
'svg' => ['width' => 1080, 'height' => 1520],
'instructions' => [
'Scanne den Code und tritt dem Event direkt bei.',
'Speichere deine Lieblingsmomente mit Foto-Uploads.',
'Merke dir dein Gäste-Pseudonym für Likes und Badges.',
],
],
'elegant-frame' => [
'id' => 'elegant-frame',
'name' => 'Elegant Frame',
'subtitle' => 'Ein ruhiges Layout mit Fokus auf Eleganz.',
'description' => 'Serifen-Schrift, pastellige Flächen und dezente Rahmen für elegante Anlässe.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#FBF7F2',
'text' => '#2B1B13',
'accent' => '#C08457',
'secondary' => '#E6D5C3',
'badge' => '#8B5CF6',
'qr' => ['size_px' => 300],
'svg' => ['width' => 1080, 'height' => 1520],
'instructions' => [
'QR-Code scannen oder Link im Browser eingeben.',
'Name eingeben, Lieblingssprache auswählen und loslegen.',
'Zeige diesen Druck am Empfang als Orientierung für Gäste.',
],
],
'bold-gradient' => [
'id' => 'bold-gradient',
'name' => 'Bold Gradient',
'subtitle' => 'Farbverlauf mit starkem Kontrast.',
'description' => 'Ein kraftvolles Farbstatement mit großem QR-Code ideal für Partys.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#F97316',
'background_gradient' => [
'angle' => 190,
'stops' => ['#F97316', '#EC4899', '#8B5CF6'],
],
'text' => '#FFFFFF',
'accent' => '#FFFFFF',
'secondary' => 'rgba(255,255,255,0.72)',
'badge' => '#1E293B',
'qr' => ['size_px' => 360],
'svg' => ['width' => 1080, 'height' => 1520],
'instructions' => [
'Sofort scannen der QR-Code führt direkt zum Event.',
'Fotos knipsen, Challenges lösen und Likes sammeln.',
'Teile den Link mit Freund:innen, falls kein Scan möglich ist.',
],
],
'photo-strip' => [
'id' => 'photo-strip',
'name' => 'Photo Strip',
'subtitle' => 'Layout mit Fotostreifen-Anmutung und Checkliste.',
'description' => 'Horizontale Teilung, Platz für Hinweise und Storytelling.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#FFFFFF',
'text' => '#111827',
'accent' => '#0EA5E9',
'secondary' => '#94A3B8',
'badge' => '#334155',
'qr' => ['size_px' => 320],
'svg' => ['width' => 1080, 'height' => 1520],
'instructions' => [
'Schritt 1: QR-Code scannen oder Kurzlink nutzen.',
'Schritt 2: Profilname eingeben kreativ sein!',
'Schritt 3: Fotos hochladen und Teamaufgaben lösen.',
],
],
'minimal-card' => [
'id' => 'minimal-card',
'name' => 'Minimal Card',
'subtitle' => 'Kleine Karte mehrfach druckbar als Tischaufsteller.',
'description' => 'Schlichtes Kartenformat mit klarer Typografie und viel Weißraum.',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#F9FAFB',
'text' => '#111827',
'accent' => '#9333EA',
'secondary' => '#E0E7FF',
'badge' => '#64748B',
'qr' => ['size_px' => 280],
'svg' => ['width' => 1080, 'height' => 1520],
'instructions' => [
'Code scannen, Profil erstellen, Erinnerungen festhalten.',
'Halte diese Karte an mehreren Stellen bereit.',
'Für Ausdrucke auf 200g/m² Kartenpapier empfohlen.',
],
],
];
/**
* Get layout definitions.
*
* @return array<int, array<string, mixed>>
*/
public static function all(): array
{
return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS));
}
/**
* Find a layout definition.
*/
public static function find(string $id): ?array
{
$layout = self::LAYOUTS[$id] ?? null;
return $layout ? self::normalize($layout) : null;
}
/**
* Normalize and merge default values.
*/
private static function normalize(array $layout): array
{
$defaults = [
'subtitle' => '',
'description' => '',
'paper' => 'a4',
'orientation' => 'portrait',
'background' => '#F9FAFB',
'text' => '#0F172A',
'accent' => '#6366F1',
'secondary' => '#CBD5F5',
'badge' => '#2563EB',
'qr' => [
'size_px' => 320,
],
'svg' => [
'width' => 1080,
'height' => 1520,
],
'background_gradient' => null,
'instructions' => [],
];
return array_replace_recursive($defaults, $layout);
}
/**
* Map layouts into an API-ready response structure, attaching URLs.
*
* @param callable(string $layoutId, string $format): string $urlResolver
* @return array<int, array<string, mixed>>
*/
public static function toResponse(callable $urlResolver): array
{
return array_map(function (array $layout) use ($urlResolver) {
$formats = ['pdf', 'svg'];
return [
'id' => $layout['id'],
'name' => $layout['name'],
'description' => $layout['description'],
'subtitle' => $layout['subtitle'],
'preview' => [
'background' => $layout['background'],
'background_gradient' => $layout['background_gradient'],
'accent' => $layout['accent'],
'text' => $layout['text'],
],
'formats' => $formats,
'download_urls' => collect($formats)
->mapWithKeys(fn ($format) => [$format => $urlResolver($layout['id'], $format)])
->all(),
];
}, self::all());
}
}