- 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:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
197
app/Support/JoinTokenLayoutRegistry.php
Normal file
197
app/Support/JoinTokenLayoutRegistry.php
Normal 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 200 g/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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user