diff --git a/app/Filament/Resources/PurchaseHistoryResource.php b/app/Filament/Resources/PurchaseHistoryResource.php index 4624925..2655107 100644 --- a/app/Filament/Resources/PurchaseHistoryResource.php +++ b/app/Filament/Resources/PurchaseHistoryResource.php @@ -8,8 +8,8 @@ use App\Models\PurchaseHistory; use BackedEnum; use Filament\Actions\BulkActionGroup; use Filament\Actions\ExportBulkAction; +use Filament\Actions\ViewAction; use Filament\Forms; -use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables; @@ -94,7 +94,7 @@ class PurchaseHistoryResource extends Resource ->label(__('admin.purchase_history.fields.platform')) ->badge() ->formatStateUsing(function ($state): string { - $key = 'admin.purchase_history.platforms.' . (string) $state; + $key = 'admin.purchase_history.platforms.'.(string) $state; $translated = __($key); return $translated === $key ? Str::headline((string) $state) : $translated; @@ -148,7 +148,7 @@ class PurchaseHistoryResource extends Resource ->searchable(), ]) ->actions([ - Tables\Actions\ViewAction::make(), + ViewAction::make(), ]) ->bulkActions([ BulkActionGroup::make([ diff --git a/app/Filament/Widgets/CreditAlertsWidget.php b/app/Filament/Widgets/CreditAlertsWidget.php index 688f143..bf16615 100644 --- a/app/Filament/Widgets/CreditAlertsWidget.php +++ b/app/Filament/Widgets/CreditAlertsWidget.php @@ -13,7 +13,7 @@ class CreditAlertsWidget extends StatsOverviewWidget protected int|string|array $columnSpan = 'full'; - protected function getCards(): array + protected function getStats(): array { $lowBalanceCount = Tenant::query() ->where('is_active', true) @@ -58,4 +58,3 @@ class CreditAlertsWidget extends StatsOverviewWidget ]; } } - diff --git a/app/Filament/Widgets/StorageCapacityWidget.php b/app/Filament/Widgets/StorageCapacityWidget.php index 208a7a2..104b28b 100644 --- a/app/Filament/Widgets/StorageCapacityWidget.php +++ b/app/Filament/Widgets/StorageCapacityWidget.php @@ -5,13 +5,13 @@ namespace App\Filament\Widgets; use App\Models\MediaStorageTarget; use App\Services\Storage\StorageHealthService; use Filament\Widgets\StatsOverviewWidget; -use Filament\Widgets\StatsOverviewWidget\Card; +use Filament\Widgets\StatsOverviewWidget\Stat; class StorageCapacityWidget extends StatsOverviewWidget { protected static ?int $sort = 1; - protected function getCards(): array + protected function getStats(): array { $health = app(StorageHealthService::class); @@ -20,7 +20,7 @@ class StorageCapacityWidget extends StatsOverviewWidget $stats = $health->getCapacity($target); if ($stats['status'] !== 'ok') { - return Card::make($target->name, 'Kapazität unbekannt') + return Stat::make($target->name, 'Kapazität unbekannt') ->description(match ($stats['status']) { 'unavailable' => 'Monitoring nicht verfügbar', 'unknown' => 'Monitor-Pfad nicht gesetzt', @@ -46,7 +46,7 @@ class StorageCapacityWidget extends StatsOverviewWidget $color = 'warning'; } - return Card::make($target->name, "$used / $total") + return Stat::make($target->name, "$used / $total") ->description("Frei: $free · Auslastung: $percent") ->color($color) ->extraAttributes([ diff --git a/app/Http/Controllers/Api/Tenant/DashboardController.php b/app/Http/Controllers/Api/Tenant/DashboardController.php index 61f8e4e..36dc7b7 100644 --- a/app/Http/Controllers/Api/Tenant/DashboardController.php +++ b/app/Http/Controllers/Api/Tenant/DashboardController.php @@ -3,13 +3,11 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; -use App\Models\Event; -use App\Models\Photo; use App\Models\Tenant; +use App\Services\Tenant\DashboardSummaryService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; -use Illuminate\Support\Carbon; class DashboardController extends Controller { @@ -32,52 +30,8 @@ class DashboardController extends Controller ], 401); } - $eventsQuery = Event::query() - ->where('tenant_id', $tenant->getKey()); + $summary = app(DashboardSummaryService::class)->build($tenant); - $activeEvents = (clone $eventsQuery) - ->where(fn ($query) => $query - ->where('is_active', true) - ->orWhere('status', 'published')) - ->count(); - - $upcomingEvents = (clone $eventsQuery) - ->whereDate('date', '>=', Carbon::now()->startOfDay()) - ->count(); - - $eventsWithTasks = (clone $eventsQuery) - ->whereHas('tasks') - ->count(); - - $totalEvents = (clone $eventsQuery)->count(); - - $taskProgress = $totalEvents > 0 - ? (int) round(($eventsWithTasks / $totalEvents) * 100) - : 0; - - $newPhotos = Photo::query() - ->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->getKey())) - ->where('created_at', '>=', Carbon::now()->subDays(7)) - ->count(); - - $activePackage = $tenant->tenantPackages() - ->with('package') - ->where('active', true) - ->orderByDesc('expires_at') - ->orderByDesc('purchased_at') - ->first(); - - return response()->json([ - 'active_events' => $activeEvents, - 'new_photos' => $newPhotos, - 'task_progress' => $taskProgress, - 'credit_balance' => $tenant->event_credits_balance ?? null, - 'upcoming_events' => $upcomingEvents, - 'active_package' => $activePackage ? [ - 'name' => $activePackage->package?->getNameForLocale(app()->getLocale()) ?? $activePackage->package?->name ?? '', - 'expires_at' => optional($activePackage->expires_at)->toIso8601String(), - 'remaining_events' => $activePackage->remaining_events ?? null, - ] : null, - ]); + return response()->json($summary); } } diff --git a/app/Http/Controllers/Api/Tenant/ProfileController.php b/app/Http/Controllers/Api/Tenant/ProfileController.php new file mode 100644 index 0000000..cacf956 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/ProfileController.php @@ -0,0 +1,134 @@ + $request->attributes->get('tenant_id'), + 'error' => $exception->getMessage(), + ]); + + return ApiError::response( + 'profile_user_missing', + 'Profil nicht verfügbar', + 'Für diesen Tenant konnte kein Account gefunden werden.', + Response::HTTP_NOT_FOUND + ); + } + + return response()->json([ + 'data' => $this->transformUser($user), + ]); + } + + public function update(ProfileUpdateRequest $request): JsonResponse + { + try { + $user = TenantAuth::resolveAdminUser($request); + } catch (\Throwable $exception) { + Log::warning('[TenantProfile] Unable to resolve user for profile update', [ + 'tenant_id' => $request->attributes->get('tenant_id'), + 'error' => $exception->getMessage(), + ]); + + return ApiError::response( + 'profile_user_missing', + 'Profil nicht verfügbar', + 'Für diesen Tenant konnte kein Account gefunden werden.', + Response::HTTP_NOT_FOUND + ); + } + + $data = $request->validated(); + $updates = []; + $emailChanged = false; + + if (isset($data['name']) && $data['name'] !== $user->name) { + $updates['name'] = $data['name']; + } + + if (array_key_exists('preferred_locale', $data) && $data['preferred_locale'] !== $user->preferred_locale) { + $updates['preferred_locale'] = $data['preferred_locale'] ? Str::lower($data['preferred_locale']) : null; + } + + if (isset($data['email']) && Str::lower($data['email']) !== Str::lower((string) $user->email)) { + $updates['email'] = $data['email']; + $updates['email_verified_at'] = null; + $emailChanged = true; + } + + if ($request->filled('password')) { + $currentPassword = (string) $request->input('current_password'); + if (! $request->filled('current_password') || ! Hash::check($currentPassword, $user->password)) { + return ApiError::response( + 'profile.invalid_current_password', + 'Aktuelles Passwort ungültig', + 'Das aktuelle Passwort stimmt nicht.', + Response::HTTP_UNPROCESSABLE_ENTITY, + [ + 'errors' => [ + 'current_password' => ['Das aktuelle Passwort stimmt nicht.'], + ], + ] + ); + } + + $updates['password'] = $request->input('password'); + } + + if ($updates !== []) { + $user->forceFill($updates); + $user->save(); + + if ($emailChanged) { + try { + $user->sendEmailVerificationNotification(); + } catch (\Throwable $exception) { + Log::error('[TenantProfile] Failed to send verification email after profile update', [ + 'user_id' => $user->getKey(), + 'tenant_id' => $request->attributes->get('tenant_id'), + 'error' => $exception->getMessage(), + ]); + } + } + } + + return response()->json([ + 'message' => 'Profil erfolgreich aktualisiert.', + 'data' => $this->transformUser($user->fresh()), + ]); + } + + /** + * @return array + */ + private function transformUser(User $user): array + { + return [ + 'id' => $user->getKey(), + 'name' => $user->name, + 'email' => $user->email, + 'preferred_locale' => $user->preferred_locale, + 'email_verified' => $user->email_verified_at !== null, + 'email_verified_at' => $user->email_verified_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 57d5704..fc6cf5a 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -9,8 +9,10 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class AuthenticatedSessionController extends Controller { @@ -28,12 +30,13 @@ class AuthenticatedSessionController extends Controller /** * Handle an incoming authentication request. */ - public function store(LoginRequest $request): RedirectResponse + public function store(LoginRequest $request): SymfonyResponse { try { $request->authenticate(); } catch (\Illuminate\Validation\ValidationException $e) { $request->session()->flash('error', __('auth.login_failed')); + return redirect()->route('login')->withErrors($e->errors()); } @@ -47,7 +50,12 @@ class AuthenticatedSessionController extends Controller return Inertia::location(route('verification.notice')); } - return Inertia::location(route('dashboard', absolute: false)); + $returnTo = $this->resolveReturnTo($request); + if ($returnTo !== null) { + return Inertia::location($returnTo); + } + + return Inertia::location($this->defaultAdminPath()); } /** @@ -62,4 +70,71 @@ class AuthenticatedSessionController extends Controller return redirect('/'); } + + private function resolveReturnTo(Request $request): ?string + { + $encoded = $request->string('return_to')->trim(); + + if ($encoded === '') { + return null; + } + + return $this->decodeReturnTo($encoded, $request); + } + + private function decodeReturnTo(string $value, Request $request): ?string + { + $candidate = $this->decodeBase64Url($value) ?? $value; + $candidate = trim($candidate); + + if ($candidate === '') { + return null; + } + + if (str_starts_with($candidate, '/')) { + return $candidate; + } + + $targetHost = parse_url($candidate, PHP_URL_HOST); + $scheme = parse_url($candidate, PHP_URL_SCHEME); + + if (! $scheme || ! $targetHost) { + return null; + } + + $appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST); + + if ($appHost && ! Str::endsWith($targetHost, $appHost)) { + return null; + } + + return $candidate; + } + + private function defaultAdminPath(): string + { + $base = rtrim(route('tenant.admin.app', absolute: false), '/'); + if ($base === '') { + $base = '/event-admin'; + } + + return $base.'/events'; + } + + private function decodeBase64Url(string $value): ?string + { + if ($value === '') { + return null; + } + + $padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '='); + $normalized = strtr($padded, '-_', '+/'); + $decoded = base64_decode($normalized, true); + + if ($decoded === false) { + return null; + } + + return $decoded; + } } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..60d7622 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,136 @@ +user(); + $tenant = $user?->tenant; + + $summary = $tenant instanceof Tenant + ? $summaryService->build($tenant) + : null; + + $events = $tenant instanceof Tenant + ? $this->collectUpcomingEvents($tenant) + : collect(); + + $purchases = $tenant instanceof Tenant + ? $this->collectRecentPurchases($tenant) + : collect(); + + $activePackage = $summary['active_package'] ?? null; + + return Inertia::render('dashboard', [ + 'metrics' => $summary, + 'upcomingEvents' => $events->values()->all(), + 'recentPurchases' => $purchases->values()->all(), + 'latestPurchase' => $purchases->first(), + 'tenant' => $tenant ? [ + 'id' => $tenant->id, + 'name' => $tenant->name, + 'eventCreditsBalance' => $tenant->event_credits_balance, + 'subscriptionStatus' => $tenant->subscription_status, + 'subscriptionExpiresAt' => optional($tenant->subscription_expires_at)->toIso8601String(), + 'activePackage' => $activePackage ? [ + 'name' => $activePackage['name'] ?? '', + 'price' => $activePackage['price'] ?? null, + 'expiresAt' => $activePackage['expires_at'] ?? null, + 'remainingEvents' => $activePackage['remaining_events'] ?? null, + ] : null, + ] : null, + 'emailVerification' => [ + 'mustVerify' => $user instanceof MustVerifyEmail, + 'verified' => $user?->hasVerifiedEmail() ?? false, + ], + ]); + } + + private function collectUpcomingEvents(Tenant $tenant): Collection + { + return Event::query() + ->where('tenant_id', $tenant->getKey()) + ->withCount(['photos', 'tasks', 'joinTokens']) + ->orderByRaw('date IS NULL') + ->orderBy('date') + ->limit(5) + ->get() + ->map(function (Event $event): array { + return [ + 'id' => $event->id, + 'name' => $this->resolveEventName($event), + 'slug' => $event->slug, + 'status' => $event->status, + 'isActive' => (bool) $event->is_active, + 'date' => optional($event->date)->toIso8601String(), + 'photosCount' => (int) ($event->photos_count ?? 0), + 'tasksCount' => (int) ($event->tasks_count ?? 0), + 'joinTokensCount' => (int) ($event->join_tokens_count ?? 0), + ]; + }); + } + + private function collectRecentPurchases(Tenant $tenant): Collection + { + return $tenant->purchases() + ->with('package') + ->latest('purchased_at') + ->limit(6) + ->get() + ->map(function (PackagePurchase $purchase): array { + return [ + 'id' => $purchase->id, + 'packageName' => $purchase->package?->getNameForLocale(app()->getLocale()) + ?? $purchase->package?->name + ?? __('Unknown package'), + 'price' => $purchase->price !== null ? (float) $purchase->price : null, + 'purchasedAt' => optional($purchase->purchased_at)->toIso8601String(), + 'type' => $purchase->type, + 'provider' => $purchase->provider, + ]; + }); + } + + private function resolveEventName(Event $event): string + { + $name = $event->name; + + if (is_array($name)) { + $locale = app()->getLocale(); + + if (! empty($name[$locale])) { + return (string) $name[$locale]; + } + + foreach (['de', 'en'] as $fallback) { + if (! empty($name[$fallback])) { + return (string) $name[$fallback]; + } + } + + $firstTranslated = reset($name); + + if (is_string($firstTranslated) && $firstTranslated !== '') { + return $firstTranslated; + } + } + + if (is_string($name) && $name !== '') { + return $name; + } + + return __('Untitled event'); + } +} diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php index 9133db8..df72406 100644 --- a/app/Http/Controllers/OAuthController.php +++ b/app/Http/Controllers/OAuthController.php @@ -7,11 +7,13 @@ use App\Models\OAuthCode; use App\Models\RefreshToken; use App\Models\Tenant; use App\Models\TenantToken; +use App\Models\User; use App\Support\ApiError; use Firebase\JWT\JWT; use GuzzleHttp\Client; use Illuminate\Http\Request; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Hash; @@ -35,6 +37,15 @@ class OAuthController extends Controller */ public function authorize(Request $request) { + if (! Auth::check()) { + return $this->authorizeErrorResponse( + $request, + 'login_required', + 'Please sign in to continue.', + Response::HTTP_UNAUTHORIZED + ); + } + $validator = Validator::make($request->all(), [ 'client_id' => 'required|string', 'redirect_uri' => 'required|url', @@ -46,7 +57,13 @@ class OAuthController extends Controller ]); if ($validator->fails()) { - return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); + return $this->authorizeErrorResponse( + $request, + 'invalid_request', + 'The authorization request is invalid.', + Response::HTTP_BAD_REQUEST, + ['errors' => $validator->errors()->toArray()] + ); } /** @var OAuthClient|null $client */ @@ -56,23 +73,47 @@ class OAuthController extends Controller ->first(); if (! $client) { - return $this->errorResponse('Invalid client', 401); + return $this->authorizeErrorResponse( + $request, + 'invalid_client', + 'The specified client could not be found.', + Response::HTTP_UNAUTHORIZED + ); } $allowedRedirects = (array) $client->redirect_uris; if (! in_array($request->redirect_uri, $allowedRedirects, true)) { - return $this->errorResponse('Invalid redirect URI', 400); + return $this->authorizeErrorResponse( + $request, + 'invalid_redirect', + 'The redirect URI is not registered for this client.', + Response::HTTP_BAD_REQUEST + ); } $requestedScopes = $this->parseScopes($request->string('scope')); $availableScopes = (array) $client->scopes; if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) { - return $this->errorResponse('Invalid scopes requested', 400); + return $this->authorizeErrorResponse( + $request, + 'invalid_scope', + 'The client requested scopes that are not permitted.', + Response::HTTP_BAD_REQUEST + ); } - $tenantId = $client->tenant_id ?? Tenant::query()->orderBy('id')->value('id'); + /** @var User $user */ + $user = Auth::user(); + + $tenantId = $this->resolveTenantId($client, $user); if (! $tenantId) { - return $this->errorResponse('Unable to resolve tenant for client', 500); + return $this->authorizeErrorResponse( + $request, + 'tenant_mismatch', + 'You do not have access to the requested tenant.', + Response::HTTP_FORBIDDEN, + ['client_id' => $client->client_id] + ); } $code = Str::random(64); @@ -95,7 +136,7 @@ class OAuthController extends Controller OAuthCode::create([ 'id' => $codeId, 'client_id' => $client->client_id, - 'user_id' => (string) $tenantId, + 'user_id' => (string) $user->getAuthIdentifier(), 'code' => Hash::make($code), 'code_challenge' => $request->code_challenge, 'state' => $request->state, @@ -120,6 +161,75 @@ class OAuthController extends Controller return redirect()->away($redirectUrl); } + private function resolveTenantId(OAuthClient $client, User $user): ?int + { + if ($client->tenant_id !== null) { + if ((int) $client->tenant_id === (int) ($user->tenant_id ?? 0) || $user->role === 'super_admin') { + return (int) $client->tenant_id; + } + + return null; + } + + if ($user->tenant_id !== null) { + return (int) $user->tenant_id; + } + + return null; + } + + private function rememberIntendedUrl(Request $request): void + { + session()->put('url.intended', $request->fullUrl()); + } + + private function authorizeErrorResponse( + Request $request, + string $code, + string $message, + int $status, + array $meta = [] + ) { + $this->rememberIntendedUrl($request); + + if ($this->shouldReturnJsonAuthorizeResponse($request)) { + $payload = [ + 'error' => $code, + 'error_description' => $message, + ]; + + if ($meta !== []) { + $payload['meta'] = $meta; + } + + return response()->json($payload, $status); + } + + $query = [ + 'error' => $code, + 'error_description' => $message, + 'return_to' => $this->encodeReturnTo($request->fullUrl()), + ]; + + if ($meta !== []) { + $metaJson = json_encode($meta, JSON_UNESCAPED_SLASHES); + if ($metaJson !== false) { + $query['error_meta'] = $this->encodeReturnTo($metaJson); + } + } + + $redirectUrl = route('tenant.admin.login').'?'.http_build_query($query); + + return redirect()->to($redirectUrl); + } + + private function encodeReturnTo(?string $value): string + { + $value ??= ''; + + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + /** * Token endpoint - Code exchange & refresh */ diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 634fc6e..df5d503 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -2,45 +2,73 @@ namespace App\Http\Controllers; -use App\Models\User; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\Request; use Inertia\Inertia; -use Illuminate\Support\Facades\Auth; +use Inertia\Response; class ProfileController extends Controller { - public function index() + public function index(Request $request): Response { - $user = Auth::user()->load('purchases.packages'); + $user = $request->user() + ->load(['tenant' => function ($query) { + $query->with([ + 'purchases' => fn ($purchases) => $purchases + ->with('package') + ->latest('purchased_at') + ->limit(10), + 'tenantPackages' => fn ($packages) => $packages + ->with('package') + ->orderByDesc('active') + ->orderByDesc('purchased_at'), + ]); + }]); + + $tenant = $user->tenant; + $activePackage = $tenant?->tenantPackages + ?->first(fn ($package) => (bool) $package->active); + + $purchases = $tenant?->purchases + ?->map(fn ($purchase) => [ + 'id' => $purchase->id, + 'packageName' => $purchase->package?->getNameForLocale(app()->getLocale()) + ?? $purchase->package?->name + ?? __('Unknown package'), + 'price' => $purchase->price !== null ? (float) $purchase->price : null, + 'purchasedAt' => optional($purchase->purchased_at)->toIso8601String(), + 'type' => $purchase->type, + 'provider' => $purchase->provider, + ]) + ->values() + ->all(); + return Inertia::render('Profile/Index', [ - 'user' => $user, + 'userData' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'username' => $user->username, + 'preferredLocale' => $user->preferred_locale, + 'emailVerifiedAt' => optional($user->email_verified_at)->toIso8601String(), + 'mustVerifyEmail' => $user instanceof MustVerifyEmail, + ], + 'tenant' => $tenant ? [ + 'id' => $tenant->id, + 'name' => $tenant->name, + 'eventCreditsBalance' => $tenant->event_credits_balance, + 'subscriptionStatus' => $tenant->subscription_status, + 'subscriptionExpiresAt' => optional($tenant->subscription_expires_at)->toIso8601String(), + 'activePackage' => $activePackage ? [ + 'name' => $activePackage->package?->getNameForLocale(app()->getLocale()) + ?? $activePackage->package?->name + ?? __('Unknown package'), + 'price' => $activePackage->price !== null ? (float) $activePackage->price : null, + 'expiresAt' => optional($activePackage->expires_at)->toIso8601String(), + 'remainingEvents' => $activePackage->remaining_events ?? null, + ] : null, + ] : null, + 'purchases' => $purchases, ]); } - - public function account() - { - $user = Auth::user()->load('purchases.packages'); - if (request()->isMethod('post')) { - $validated = request()->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|email|unique:users,email,' . $user->id, - ]); - - $user->update($validated); - - return back()->with('success', 'Profil aktualisiert.'); - } - - return Inertia::render('Profile/Account', [ - 'user' => $user, - ]); - } - - public function orders() - { - $user = Auth::user()->load('purchases.packages'); - return Inertia::render('Profile/Orders', [ - 'purchases' => $user->purchases, - ]); - } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/TenantAdminGoogleController.php b/app/Http/Controllers/TenantAdminGoogleController.php new file mode 100644 index 0000000..36f1165 --- /dev/null +++ b/app/Http/Controllers/TenantAdminGoogleController.php @@ -0,0 +1,105 @@ +query('return_to'); + if (is_string($returnTo) && $returnTo !== '') { + $request->session()->put('tenant_oauth_return_to', $returnTo); + } + + return Socialite::driver('google') + ->scopes(['openid', 'profile', 'email']) + ->with(['prompt' => 'select_account']) + ->redirect(); + } + + public function callback(Request $request) + { + try { + $googleUser = Socialite::driver('google')->user(); + } catch (Throwable $exception) { + Log::warning('Tenant admin Google sign-in failed', [ + 'message' => $exception->getMessage(), + ]); + + return $this->sendBackWithError($request, 'google_failed', 'Unable to complete Google sign-in.'); + } + + $email = $googleUser->getEmail(); + if (! $email) { + return $this->sendBackWithError($request, 'google_failed', 'Google account did not provide an email address.'); + } + + /** @var User|null $user */ + $user = User::query()->where('email', $email)->first(); + + if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { + return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.'); + } + + $user->forceFill([ + 'name' => $googleUser->getName() ?: $user->name, + 'email_verified_at' => $user->email_verified_at ?? now(), + ])->save(); + + Auth::login($user, true); + $request->session()->regenerate(); + + $returnTo = $request->session()->pull('tenant_oauth_return_to'); + if (is_string($returnTo)) { + $decoded = $this->decodeReturnTo($returnTo, $request); + if ($decoded) { + return redirect()->to($decoded); + } + } + + return redirect()->intended(route('tenant.admin.app')); + } + + private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse + { + $query = [ + 'error' => $code, + 'error_description' => $message, + ]; + + if ($request->session()->has('tenant_oauth_return_to')) { + $query['return_to'] = $request->session()->get('tenant_oauth_return_to'); + } + + return redirect()->route('tenant.admin.login', $query); + } + + private function decodeReturnTo(string $encoded, Request $request): ?string + { + $padded = str_pad($encoded, strlen($encoded) + ((4 - (strlen($encoded) % 4)) % 4), '='); + $normalized = strtr($padded, '-_', '+/'); + $decoded = base64_decode($normalized); + + if (! is_string($decoded) || $decoded === '') { + return null; + } + + $targetHost = parse_url($decoded, PHP_URL_HOST); + $appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST); + + if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) { + return null; + } + + return $decoded; + } +} diff --git a/app/Http/Requests/Tenant/ProfileUpdateRequest.php b/app/Http/Requests/Tenant/ProfileUpdateRequest.php new file mode 100644 index 0000000..838e800 --- /dev/null +++ b/app/Http/Requests/Tenant/ProfileUpdateRequest.php @@ -0,0 +1,53 @@ +getKey(); + } catch (\Throwable $e) { + $userId = null; + } + + $supportedLocales = config('app.supported_locales'); + if (! is_array($supportedLocales) || empty($supportedLocales)) { + $supportedLocales = array_filter([ + config('app.locale', 'de'), + config('app.fallback_locale', 'en'), + ]); + } + + return [ + 'name' => ['required', 'string', 'max:120'], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users', 'email')->ignore($userId), + ], + 'preferred_locale' => [ + 'nullable', + 'string', + Rule::in($supportedLocales), + ], + 'current_password' => ['required_with:password', 'string'], + 'password' => ['nullable', Password::defaults(), 'confirmed'], + ]; + } +} diff --git a/app/Services/Tenant/DashboardSummaryService.php b/app/Services/Tenant/DashboardSummaryService.php new file mode 100644 index 0000000..c23103a --- /dev/null +++ b/app/Services/Tenant/DashboardSummaryService.php @@ -0,0 +1,75 @@ +where('tenant_id', $tenant->getKey()); + + $totalEvents = (clone $eventsQuery)->count(); + + $activeEvents = (clone $eventsQuery) + ->where(static function ($query) { + $query->where('is_active', true) + ->orWhere('status', 'published'); + }) + ->count(); + + $publishedEvents = (clone $eventsQuery) + ->where('status', 'published') + ->count(); + + $eventsWithTasks = (clone $eventsQuery) + ->whereHas('tasks') + ->count(); + + $upcomingEvents = (clone $eventsQuery) + ->whereDate('date', '>=', Carbon::now()->startOfDay()) + ->count(); + + $newPhotos = Photo::query() + ->whereHas('event', static function ($query) use ($tenant) { + $query->where('tenant_id', $tenant->getKey()); + }) + ->where('created_at', '>=', Carbon::now()->subDays(7)) + ->count(); + + /** @var TenantPackage|null $activePackage */ + $activePackage = $tenant->tenantPackages() + ->with('package') + ->where('active', true) + ->orderByDesc('expires_at') + ->orderByDesc('purchased_at') + ->first(); + + return [ + 'total_events' => $totalEvents, + 'active_events' => $activeEvents, + 'published_events' => $publishedEvents, + 'events_with_tasks' => $eventsWithTasks, + 'upcoming_events' => $upcomingEvents, + 'new_photos' => $newPhotos, + 'task_progress' => $totalEvents > 0 + ? (int) round(($eventsWithTasks / $totalEvents) * 100) + : 0, + 'credit_balance' => $tenant->event_credits_balance ?? null, + 'active_package' => $activePackage ? [ + 'name' => $activePackage->package?->getNameForLocale(app()->getLocale()) + ?? $activePackage->package?->name + ?? '', + 'expires_at' => optional($activePackage->expires_at)->toIso8601String(), + 'remaining_events' => $activePackage->remaining_events ?? null, + 'price' => $activePackage->price !== null ? (float) $activePackage->price : null, + ] : null, + ]; + } +} diff --git a/app/Support/TenantAuth.php b/app/Support/TenantAuth.php new file mode 100644 index 0000000..166705d --- /dev/null +++ b/app/Support/TenantAuth.php @@ -0,0 +1,54 @@ +attributes->get('decoded_token', []); + $tenantId = $request->attributes->get('tenant_id') + ?? $request->input('tenant_id') + ?? Arr::get($decoded, 'tenant_id'); + + if (! $tenantId) { + throw (new ModelNotFoundException)->setModel(User::class); + } + + $userId = Arr::get($decoded, 'user_id'); + + if ($userId) { + $user = User::query() + ->whereKey($userId) + ->where('tenant_id', $tenantId) + ->first(); + + if ($user) { + return $user; + } + } + + $user = User::query() + ->where('tenant_id', $tenantId) + ->whereIn('role', ['tenant_admin', 'admin']) + ->orderByDesc('email_verified_at') + ->orderBy('id') + ->first(); + + if (! $user) { + throw (new ModelNotFoundException)->setModel(User::class); + } + + return $user; + } +} diff --git a/database/seeders/DemoAchievementsSeeder.php b/database/seeders/DemoAchievementsSeeder.php index 72b395f..87c53ea 100644 --- a/database/seeders/DemoAchievementsSeeder.php +++ b/database/seeders/DemoAchievementsSeeder.php @@ -17,19 +17,11 @@ class DemoAchievementsSeeder extends Seeder { public function run(): void { - $event = Event::where('slug', 'demo-wedding-2025')->first(); - $tenant = Tenant::where('slug', 'demo')->first(); + $tenant = Tenant::where('slug', 'demo-tenant')->first(); - if (! $event || ! $tenant) { - $this->command?->warn('Demo event/tenant missing – skipping DemoAchievementsSeeder'); - return; - } + if (! $tenant) { + $this->command?->warn('Demo tenant missing – skipping DemoAchievementsSeeder'); - $tasks = Task::where('tenant_id', $tenant->id)->pluck('id')->all(); - $emotions = Emotion::pluck('id')->all(); - - if ($tasks === [] || $emotions === []) { - $this->command?->warn('Tasks or emotions missing – skipping DemoAchievementsSeeder'); return; } @@ -39,79 +31,134 @@ class DemoAchievementsSeeder extends Seeder if ($sourceFiles->isEmpty()) { $this->command?->warn('No demo photo files found – skipping DemoAchievementsSeeder'); + return; } - $blueprints = [ - ['guest' => 'Anna Mueller', 'photos' => 6, 'likes' => [12, 8, 5, 4, 2, 1], 'withTasks' => true], - ['guest' => 'Max Schmidt', 'photos' => 4, 'likes' => [9, 7, 4, 2], 'withTasks' => true], - ['guest' => 'Lisa Weber', 'photos' => 2, 'likes' => [3, 1], 'withTasks' => false], - ['guest' => 'Tom Fischer', 'photos' => 1, 'likes' => [14], 'withTasks' => true], - ['guest' => 'Team Brautparty', 'photos' => 5, 'likes' => [5, 4, 3, 3, 2], 'withTasks' => true], - ]; + $emotions = Emotion::pluck('id')->all(); + if ($emotions === []) { + $this->command?->warn('No emotions available – skipping DemoAchievementsSeeder'); - $eventDate = $event->date ? CarbonImmutable::parse($event->date) : CarbonImmutable::now(); - $baseDir = "events/{$event->id}/achievements"; - Storage::disk('public')->makeDirectory($baseDir); - Storage::disk('public')->makeDirectory("{$baseDir}/thumbs"); - - $photoIndex = 0; - - foreach ($blueprints as $groupIndex => $blueprint) { - for ($i = 0; $i < $blueprint['photos']; $i++) { - $source = $sourceFiles[$photoIndex % $sourceFiles->count()]; - $photoIndex++; - - $filename = Str::slug($blueprint['guest'] . '-' . $groupIndex . '-' . $i) . '.jpg'; - $destPath = "{$baseDir}/{$filename}"; - if (! Storage::disk('public')->exists($destPath)) { - Storage::disk('public')->copy($source, $destPath); - } - - $thumbSource = str_replace('photos/', 'thumbnails/', $source); - $thumbDest = "{$baseDir}/thumbs/{$filename}"; - if (Storage::disk('public')->exists($thumbSource)) { - Storage::disk('public')->copy($thumbSource, $thumbDest); - } else { - Storage::disk('public')->copy($source, $thumbDest); - } - - $taskId = $blueprint['withTasks'] ? $tasks[($groupIndex + $i) % count($tasks)] : null; - $emotionId = $emotions[($groupIndex * 3 + $i) % count($emotions)]; - $createdAt = $eventDate->addHours($groupIndex * 2 + $i); - $likes = $blueprint['likes'][$i] ?? 0; - - $photo = Photo::updateOrCreate( - [ - 'tenant_id' => $tenant->id, - 'event_id' => $event->id, - 'guest_name' => $blueprint['guest'], - 'file_path' => $destPath, - ], - [ - 'task_id' => $taskId, - 'emotion_id' => $emotionId, - 'thumbnail_path' => $thumbDest, - 'likes_count' => $likes, - 'is_featured' => $i === 0, - 'metadata' => ['demo' => true], - 'created_at' => $createdAt, - 'updated_at' => $createdAt, - ] - ); - - PhotoLike::where('photo_id', $photo->id)->delete(); - for ($like = 0; $like < min($likes, 15); $like++) { - PhotoLike::create([ - 'photo_id' => $photo->id, - 'guest_name' => 'Guest_' . Str::random(6), - 'ip_address' => '10.0.' . rand(0, 254) . '.' . rand(0, 254), - 'created_at' => $createdAt->addMinutes($like * 3), - ]); - } - } + return; } - $this->command?->info('Demo achievements seeded.'); + $scenarios = [ + [ + 'event' => Event::with(['tasks', 'eventType']) + ->where('slug', 'demo-wedding-2025') + ->first(), + 'blueprints' => [ + ['guest' => 'Anna Mueller', 'photos' => 6, 'likes' => [12, 8, 5, 4, 2, 1], 'withTasks' => true], + ['guest' => 'Max Schmidt', 'photos' => 4, 'likes' => [9, 7, 4, 2], 'withTasks' => true], + ['guest' => 'Lisa Weber', 'photos' => 2, 'likes' => [3, 1], 'withTasks' => false], + ['guest' => 'Tom Fischer', 'photos' => 1, 'likes' => [14], 'withTasks' => true], + ['guest' => 'Team Brautparty', 'photos' => 5, 'likes' => [5, 4, 3, 3, 2], 'withTasks' => true], + ], + ], + [ + 'event' => Event::with(['tasks', 'eventType']) + ->where('slug', 'demo-corporate-2025') + ->first(), + 'blueprints' => [ + ['guest' => 'HR Dream Team', 'photos' => 4, 'likes' => [8, 6, 4, 3], 'withTasks' => true], + ['guest' => 'Innovation Squad', 'photos' => 5, 'likes' => [10, 7, 5, 4, 2], 'withTasks' => true], + ['guest' => 'Finance Crew', 'photos' => 3, 'likes' => [6, 5, 2], 'withTasks' => false], + ['guest' => 'New Joiners', 'photos' => 4, 'likes' => [5, 4, 3, 2], 'withTasks' => true], + ], + ], + ]; + + foreach ($scenarios as $scenario) { + /** @var Event|null $event */ + $event = $scenario['event']; + + if (! $event) { + $this->command?->warn('Demo event missing – skipping achievements for one scenario.'); + + continue; + } + + $taskIds = $event->tasks()->pluck('tasks.id')->all(); + if ($taskIds === []) { + $taskIds = Task::where('event_type_id', $event->event_type_id ?? optional($event->eventType)->id) + ->pluck('id') + ->all(); + } + + if ($taskIds === []) { + $this->command?->warn(sprintf('No tasks available for %s – achievements will use taskless entries.', $event->slug)); + } + + $eventDate = $event->date ? CarbonImmutable::parse($event->date) : CarbonImmutable::now(); + $baseDir = "events/{$event->id}/achievements"; + + Storage::disk('public')->makeDirectory($baseDir); + Storage::disk('public')->makeDirectory("{$baseDir}/thumbs"); + + $photoIndex = 0; + + foreach ($scenario['blueprints'] as $groupIndex => $blueprint) { + for ($i = 0; $i < $blueprint['photos']; $i++) { + $sourcePath = $sourceFiles[$photoIndex % $sourceFiles->count()]; + $photoIndex++; + + $filename = Str::slug($blueprint['guest'].'-'.$groupIndex.'-'.$i).'.jpg'; + $destPath = "{$baseDir}/{$filename}"; + + if (! Storage::disk('public')->exists($destPath)) { + Storage::disk('public')->copy($sourcePath, $destPath); + } + + $thumbSource = str_replace('photos/', 'thumbnails/', $sourcePath); + $thumbDest = "{$baseDir}/thumbs/{$filename}"; + + if (Storage::disk('public')->exists($thumbSource)) { + Storage::disk('public')->copy($thumbSource, $thumbDest); + } else { + Storage::disk('public')->copy($sourcePath, $thumbDest); + } + + $taskId = null; + if (! empty($taskIds) && ($blueprint['withTasks'] ?? false)) { + $taskId = $taskIds[($groupIndex + $i) % count($taskIds)]; + } + + $emotionId = $emotions[($groupIndex * 3 + $i) % count($emotions)]; + $createdAt = $eventDate->addHours($groupIndex * 2 + $i); + $likes = $blueprint['likes'][$i] ?? 0; + + $photo = Photo::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + 'guest_name' => $blueprint['guest'], + 'file_path' => $destPath, + ], + [ + 'task_id' => $taskId, + 'emotion_id' => $emotionId, + 'thumbnail_path' => $thumbDest, + 'likes_count' => $likes, + 'is_featured' => $i === 0, + 'metadata' => ['demo' => true, 'achievement' => true], + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ] + ); + + PhotoLike::where('photo_id', $photo->id)->delete(); + for ($like = 0; $like < min($likes, 15); $like++) { + PhotoLike::create([ + 'photo_id' => $photo->id, + 'guest_name' => 'Guest_'.Str::random(6), + 'ip_address' => '10.0.'.rand(0, 254).'.'.rand(0, 254), + 'created_at' => $createdAt->addMinutes($like * 3), + ]); + } + } + } + + $this->command?->info(sprintf('Demo achievements seeded for %s.', $event->slug)); + } } } diff --git a/database/seeders/DemoEventSeeder.php b/database/seeders/DemoEventSeeder.php index e372e41..ba31106 100644 --- a/database/seeders/DemoEventSeeder.php +++ b/database/seeders/DemoEventSeeder.php @@ -7,79 +7,216 @@ use App\Models\EventPackage; use App\Models\EventType; use App\Models\Package; use App\Models\PackagePurchase; +use App\Models\Task; +use App\Models\TaskCollection; use App\Models\Tenant; use App\Services\EventJoinTokenService; use Illuminate\Database\Seeder; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Schema; class DemoEventSeeder extends Seeder { public function run(): void { - $type = EventType::where('slug', 'wedding')->first(); - if (! $type) { - return; - } $demoTenant = Tenant::where('slug', 'demo-tenant')->first(); if (! $demoTenant) { return; } - $event = Event::updateOrCreate(['slug' => 'demo-wedding-2025'], [ - 'tenant_id' => $demoTenant->id, - 'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'], - 'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'], - 'date' => now()->addMonths(3)->toDateString(), - 'event_type_id' => $type->id, - 'status' => 'published', - 'is_active' => true, - 'settings' => json_encode([]), - 'default_locale' => 'de', - ]); - if ($event->joinTokens()->count() === 0) { - /** @var EventJoinTokenService $service */ - $service = app(EventJoinTokenService::class); - $service->createToken($event, [ - 'label' => 'Demo QR', - ]); - } + $weddingType = EventType::where('slug', 'wedding')->first(); + $corporateType = EventType::where('slug', 'corporate')->first(); - $package = Package::where('slug', 'standard')->first(); - if (! $package) { - $package = Package::where('type', 'endcustomer')->orderBy('price')->first(); - } + $standardPackage = Package::where('slug', 'standard')->first() + ?? Package::where('type', 'endcustomer')->orderBy('price')->first(); + $premiumPackage = Package::where('slug', 'premium')->first() + ?? Package::where('type', 'endcustomer')->orderByDesc('price')->first(); - if ($package) { - $eventPackageData = [ - 'purchased_price' => $package->price, - 'purchased_at' => now()->subDays(7), - ]; - - if (Schema::hasColumn('event_packages', 'used_photos')) { - $eventPackageData['used_photos'] = 0; - } - if (Schema::hasColumn('event_packages', 'used_guests')) { - $eventPackageData['used_guests'] = 0; - } - if (Schema::hasColumn('event_packages', 'gallery_expires_at')) { - $eventPackageData['gallery_expires_at'] = now()->addDays($package->gallery_days ?? 30); - } - - EventPackage::updateOrCreate( - [ - 'event_id' => $event->id, - 'package_id' => $package->id, + $events = [ + [ + 'slug' => 'demo-wedding-2025', + 'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'], + 'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'], + 'date' => Carbon::now()->addMonths(3), + 'event_type' => $weddingType, + 'package' => $standardPackage, + 'token_label' => 'Demo QR', + 'collection_slugs' => ['wedding-classics-2025'], + 'task_slug_prefix' => 'wedding-', + 'branding' => [ + 'primary_color' => '#f43f5e', + 'secondary_color' => '#fb7185', + 'background_color' => '#fff7f4', + 'font_family' => 'Playfair Display, serif', ], - $eventPackageData + ], + [ + 'slug' => 'demo-corporate-2025', + 'name' => ['de' => 'Demo Firmen-Event 2025', 'en' => 'Demo Corporate Summit 2025'], + 'description' => ['de' => 'Launch-Event mit Networking', 'en' => 'Launch event with networking sessions'], + 'date' => Carbon::now()->addMonths(2), + 'event_type' => $corporateType, + 'package' => $premiumPackage, + 'token_label' => 'Corporate QR', + 'collection_slugs' => ['corporate-classics-2025'], + 'task_slug_prefix' => 'corporate-', + 'branding' => [ + 'primary_color' => '#0ea5e9', + 'secondary_color' => '#2563eb', + 'background_color' => '#0f172a', + 'font_family' => 'Inter, sans-serif', + ], + ], + ]; + + foreach ($events as $config) { + if (! $config['event_type'] || ! $config['package']) { + continue; + } + + $event = Event::updateOrCreate( + ['slug' => $config['slug']], + [ + 'tenant_id' => $demoTenant->id, + 'name' => $config['name'], + 'description' => $config['description'], + 'date' => $config['date']->toDateString(), + 'event_type_id' => $config['event_type']->id, + 'status' => 'published', + 'is_active' => true, + 'settings' => [ + 'branding' => $config['branding'], + ], + 'default_locale' => 'de', + ] ); - PackagePurchase::query() - ->where('tenant_id', $demoTenant->id) - ->where('package_id', $package->id) - ->where('provider_id', 'demo-seed') - ->update([ - 'event_id' => $event->id, - ]); + $this->ensureJoinToken($event, $config['token_label']); + + $this->attachEventPackage( + event: $event, + package: $config['package'], + tenant: $demoTenant, + providerId: 'demo-seed-'.$config['slug'], + purchasedAt: Carbon::now()->subDays(7) + ); + + $this->attachTaskCollections($event, $config['collection_slugs']); + $this->attachEventTasks($event, $config['task_slug_prefix']); } } + + private function ensureJoinToken(Event $event, string $label): void + { + if ($event->joinTokens()->exists()) { + return; + } + + app(EventJoinTokenService::class)->createToken($event, ['label' => $label]); + } + + private function attachEventPackage(Event $event, Package $package, Tenant $tenant, string $providerId, Carbon $purchasedAt): void + { + $eventPackageData = [ + 'purchased_price' => $package->price, + 'purchased_at' => $purchasedAt, + ]; + + if (Schema::hasColumn('event_packages', 'used_photos')) { + $eventPackageData['used_photos'] = 0; + } + + if (Schema::hasColumn('event_packages', 'used_guests')) { + $eventPackageData['used_guests'] = 0; + } + + if (Schema::hasColumn('event_packages', 'gallery_expires_at')) { + $eventPackageData['gallery_expires_at'] = $purchasedAt->copy()->addDays($package->gallery_days ?? 30); + } + + EventPackage::updateOrCreate( + [ + 'event_id' => $event->id, + 'package_id' => $package->id, + ], + $eventPackageData + ); + + PackagePurchase::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => $providerId, + ], + [ + 'event_id' => $event->id, + 'price' => $package->price, + 'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event', + 'purchased_at' => $purchasedAt, + 'metadata' => ['demo' => true, 'event_slug' => $event->slug], + 'ip_address' => null, + 'user_agent' => null, + ] + ); + } + + private function attachTaskCollections(Event $event, array $collectionSlugs): void + { + if ($collectionSlugs === []) { + return; + } + + $collections = TaskCollection::whereIn('slug', $collectionSlugs)->get(); + + $pivot = []; + foreach ($collections as $index => $collection) { + $pivot[$collection->id] = ['sort_order' => ($index + 1) * 10]; + } + + if ($pivot !== []) { + $event->taskCollections()->syncWithoutDetaching($pivot); + } + } + + private function attachEventTasks(Event $event, string $slugPrefix): void + { + $tasks = []; + + if ($event->event_type_id) { + $tasks = Task::where('event_type_id', $event->event_type_id) + ->orderBy('sort_order') + ->limit(25) + ->pluck('id') + ->all(); + } + + if ($tasks === [] && $slugPrefix !== '') { + $tasks = Task::where('slug', 'like', $slugPrefix.'%') + ->orderBy('sort_order') + ->limit(25) + ->pluck('id') + ->all(); + } + + if ($tasks === []) { + $tasks = Task::where('tenant_id', $event->tenant_id) + ->orderBy('sort_order') + ->limit(20) + ->pluck('id') + ->all(); + } + + if ($tasks === []) { + return; + } + + $tasks = array_slice(array_unique($tasks), 0, 20); + + $pivot = []; + foreach ($tasks as $index => $taskId) { + $pivot[$taskId] = ['sort_order' => ($index + 1) * 10]; + } + + $event->tasks()->syncWithoutDetaching($pivot); + } } diff --git a/database/seeders/DemoPhotosSeeder.php b/database/seeders/DemoPhotosSeeder.php index 396366b..f245f9c 100644 --- a/database/seeders/DemoPhotosSeeder.php +++ b/database/seeders/DemoPhotosSeeder.php @@ -2,101 +2,189 @@ namespace Database\Seeders; -use Illuminate\Database\Seeder; -use Illuminate\Support\Str; -use App\Models\{Event, Task, Emotion, Photo, PhotoLike, Tenant}; -use Illuminate\Support\Facades\File; +use App\Models\Emotion; +use App\Models\Event; +use App\Models\Photo; +use App\Models\PhotoLike; +use App\Models\Task; +use App\Models\Tenant; use Carbon\Carbon; +use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class DemoPhotosSeeder extends Seeder { public function run(): void { - // Get demo event and tenant - $demoEvent = Event::where('slug', 'demo-wedding-2025')->first(); - $demoTenant = Tenant::where('slug', 'demo')->first(); - - if (!$demoEvent || !$demoTenant) { - $this->command->info('Demo event or tenant not found, skipping DemoPhotosSeeder'); + $tenant = Tenant::where('slug', 'demo-tenant')->first(); + + if (! $tenant) { + $this->command->info('Demo tenant not found, skipping DemoPhotosSeeder'); + + return; + } + + $photoDir = storage_path('app/public/photos'); + if (! File::exists($photoDir)) { + $this->command->info('No demo photos available in storage/app/public/photos'); + + return; + } + + $photoFiles = collect(File::files($photoDir)) + ->filter(fn ($file) => str_ends_with(strtolower($file->getFilename()), '.jpg')) + ->values(); + + if ($photoFiles->isEmpty()) { + $this->command->info('No JPG demo photos found, skipping DemoPhotosSeeder'); + return; } - // Get all available tasks and emotions - $tasks = Task::where('tenant_id', $demoTenant->id)->get(); $emotions = Emotion::all(); + if ($emotions->isEmpty()) { + $this->command->info('No emotions available, skipping DemoPhotosSeeder'); - if ($tasks->isEmpty() || $emotions->isEmpty()) { - $this->command->info('No tasks or emotions found, skipping DemoPhotosSeeder'); return; } - // List of 20 guest names (ASCII only to avoid encoding issues) - $guestNames = [ - 'Anna Mueller', 'Max Schmidt', 'Lisa Weber', 'Tom Fischer', 'Sophie Bauer', - 'Lukas Hoffmann', 'Emma Wagner', 'Jonas Klein', 'Mia Schwarz', 'Felix Becker', - 'Lena Richter', 'Paul Lehmann', 'Julia Neumann', 'David Vogel', 'Sara Krueger', - 'Tim Berger', 'Nina Wolf', 'Ben Schaefer', 'Laura Stein', 'Moritz Fuchs' + $events = [ + [ + 'model' => Event::with(['tasks', 'eventPackage', 'eventPackages', 'eventType']) + ->where('slug', 'demo-wedding-2025') + ->first(), + 'guest_names' => [ + 'Anna Mueller', 'Max Schmidt', 'Lisa Weber', 'Tom Fischer', 'Sophie Bauer', + 'Lukas Hoffmann', 'Emma Wagner', 'Jonas Klein', 'Mia Schwarz', 'Felix Becker', + 'Lena Richter', 'Paul Lehmann', 'Julia Neumann', 'David Vogel', 'Sara Krueger', + 'Tim Berger', 'Nina Wolf', 'Ben Schaefer', 'Laura Stein', 'Moritz Fuchs', + ], + 'like_range' => [4, 18], + ], + [ + 'model' => Event::with(['tasks', 'eventPackage', 'eventPackages', 'eventType']) + ->where('slug', 'demo-corporate-2025') + ->first(), + 'guest_names' => [ + 'Clara Schmidt', 'Jan Becker', 'Noah Winkler', 'Sina Albrecht', 'Kai Lenz', + 'Tara Nguyen', 'Omar Hassan', 'Elias Roth', 'Greta Sommer', 'Leonard Busch', + 'Verena Graf', 'Nico Adler', 'Johanna Kurz', 'Fabian Scholz', 'Mara Kranz', + 'Yuki Tanaka', 'Mateo Ruiz', 'Amina Korb', 'Philipp Krüger', 'Selma Vogt', + ], + 'like_range' => [2, 12], + ], ]; - // Get all photo files from storage - $photoDir = storage_path('app/public/photos'); - $photoFiles = File::files($photoDir); - - $seededCount = 0; - foreach ($photoFiles as $file) { - $filename = $file->getFilename(); - if (!str_ends_with($filename, '.jpg')) { + foreach ($events as $config) { + /** @var Event|null $event */ + $event = $config['model']; + + if (! $event) { + $this->command->warn('Demo event missing, skipping photo seeding.'); + continue; } - // Check if already seeded (avoid duplicates) - if (Photo::where('file_path', 'photos/' . $filename)->exists()) { + $taskIds = $event->tasks()->pluck('tasks.id')->all(); + if ($taskIds === []) { + $eventTypeId = $event->event_type_id ?? optional($event->eventType)->id; + $taskIds = Task::where('event_type_id', $eventTypeId)->pluck('id')->all(); + } + + if ($taskIds === []) { + $this->command->warn(sprintf('No tasks assigned to %s. Photos will be seeded without task references.', $event->slug)); + } + + $guestNames = $config['guest_names']; + $photosToSeed = min($photoFiles->count(), count($guestNames)); + + if ($photosToSeed === 0) { continue; } - // Generate thumbnail path - $thumbnailFilename = str_replace('.jpg', '_thumb.jpg', $filename); - $thumbnailPath = 'thumbnails/' . $thumbnailFilename; + $storage = Storage::disk('public'); + $storage->makeDirectory("events/{$event->id}/gallery"); + $storage->makeDirectory("events/{$event->id}/gallery/thumbs"); - // Random assignments - $randomTask = $tasks->random(); - $randomEmotion = $emotions->random(); - $randomGuest = $guestNames[array_rand($guestNames)]; - $randomLikes = rand(0, 20); - $eventDate = $demoEvent->date; - $randomUploadedAt = Carbon::parse($eventDate)->addHours(rand(0, 24))->addMinutes(rand(0, 59)); + $photosSeeded = 0; - // Create photo - $photo = Photo::create([ - 'tenant_id' => $demoTenant->id, - 'event_id' => $demoEvent->id, - 'task_id' => $randomTask->id, - 'emotion_id' => $randomEmotion->id, - 'guest_name' => $randomGuest, - 'file_path' => 'photos/' . $filename, - 'thumbnail_path' => $thumbnailPath, - 'likes_count' => $randomLikes, - 'is_featured' => false, - 'metadata' => [], - 'created_at' => $randomUploadedAt, - 'updated_at' => $randomUploadedAt, - ]); + for ($i = 0; $i < $photosToSeed; $i++) { + $sourceFile = $photoFiles->get($i % $photoFiles->count()); + $baseName = pathinfo($sourceFile->getFilename(), PATHINFO_FILENAME); + $guestName = $guestNames[$i]; + $likes = rand($config['like_range'][0], $config['like_range'][1]); + $timestamp = Carbon::parse($event->date ?? now()) + ->addHours(rand(0, 36)) + ->addMinutes(rand(0, 59)); - // Add random likes - if ($randomLikes > 0) { - for ($i = 0; $i < $randomLikes; $i++) { + $filename = sprintf('%s-demo-%02d.jpg', $event->slug, $i + 1); + $destPath = "events/{$event->id}/gallery/{$filename}"; + + if (! $storage->exists($destPath)) { + $storage->put($destPath, File::get($sourceFile->getRealPath())); + } + + $thumbFilename = sprintf('%s-demo-%02d_thumb.jpg', $event->slug, $i + 1); + $thumbDest = "events/{$event->id}/gallery/thumbs/{$thumbFilename}"; + $existingThumb = "thumbnails/{$baseName}_thumb.jpg"; + + if ($storage->exists($existingThumb)) { + if (! $storage->exists($thumbDest)) { + $storage->copy($existingThumb, $thumbDest); + } + } else { + if (! $storage->exists($thumbDest)) { + $storage->put($thumbDest, File::get($sourceFile->getRealPath())); + } + } + + $taskId = $taskIds ? $taskIds[array_rand($taskIds)] : null; + $emotionId = $emotions->random()->id; + + $photo = Photo::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + 'file_path' => $destPath, + ], + [ + 'task_id' => $taskId, + 'emotion_id' => $emotionId, + 'guest_name' => $guestName, + 'thumbnail_path' => $thumbDest, + 'likes_count' => $likes, + 'is_featured' => $i === 0, + 'metadata' => ['demo' => true], + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ] + ); + + PhotoLike::where('photo_id', $photo->id)->delete(); + $maxLikes = min($likes, 10); + for ($like = 0; $like < $maxLikes; $like++) { PhotoLike::create([ 'photo_id' => $photo->id, - 'guest_name' => 'GuestLike_' . Str::random(6), - 'ip_address' => '10.0.' . rand(0, 254) . '.' . rand(1, 254), - 'created_at' => $randomUploadedAt->clone()->addMinutes(rand(0, 60)), + 'guest_name' => 'GuestLike_'.Str::random(6), + 'ip_address' => '10.0.'.rand(0, 254).'.'.rand(1, 254), + 'created_at' => $timestamp->copy()->addMinutes($like * 3), ]); } + + $photosSeeded++; } - $seededCount++; - } + $eventPackage = $event->eventPackage ?? $event->eventPackages()->orderByDesc('purchased_at')->first(); + if ($eventPackage) { + $eventPackage->forceFill([ + 'used_photos' => max($eventPackage->used_photos ?? 0, $photosSeeded), + 'used_guests' => max($eventPackage->used_guests ?? 0, count(array_unique($guestNames))), + ])->save(); + } - $this->command->info(sprintf('Seeded %d demo photos with random tasks, emotions, uploaders, and likes', $seededCount)); + $this->command->info(sprintf('Seeded %d demo photos for %s', $photosSeeded, $event->slug)); + } } } diff --git a/database/seeders/E2ETenantSeeder.php b/database/seeders/E2ETenantSeeder.php deleted file mode 100644 index 5c55e1a..0000000 --- a/database/seeders/E2ETenantSeeder.php +++ /dev/null @@ -1,56 +0,0 @@ - ]); - ->fill([ - 'name' => ->name ?? 'E2E Tenant Admin', - 'first_name' => ->first_name ?? 'E2E', - 'last_name' => ->last_name ?? 'Admin', - 'role' => 'tenant_admin', - 'pending_purchase' => false, - ]); - - ->password = Hash::make(); - ->email_verified_at = now(); - ->save(); - - = Tenant::firstOrNew(['user_id' => ->id]); - ->fill([ - 'name' => ->name ?? 'E2E Test Tenant', - 'slug' => ->slug ?? Str::slug('e2e-tenant-' . ->id), - 'email' => , - 'is_active' => true, - 'is_suspended' => false, - 'event_credits_balance' => ->event_credits_balance ?? 1, - 'subscription_status' => ->subscription_status ?? 'active', - 'settings' => ->settings ?? [ - 'branding' => [ - 'logo_url' => null, - 'primary_color' => '#ef476f', - 'secondary_color' => '#ffd166', - 'font_family' => 'Montserrat, sans-serif', - ], - 'features' => [ - 'photo_likes_enabled' => true, - ], - 'contact_email' => , - 'event_default_type' => 'general', - ], - ]); - ->save(); - } -} \ No newline at end of file diff --git a/database/seeders/EventTasksSeeder.php b/database/seeders/EventTasksSeeder.php index 03852bb..75c7eb2 100644 --- a/database/seeders/EventTasksSeeder.php +++ b/database/seeders/EventTasksSeeder.php @@ -2,18 +2,20 @@ namespace Database\Seeders; +use App\Models\Emotion; +use App\Models\Task; +use App\Models\Tenant; use Illuminate\Database\Seeder; -use Illuminate\Support\Str; -use App\Models\{Emotion, Task, Tenant}; class EventTasksSeeder extends Seeder { public function run(): void { // Get demo tenant - $demoTenant = Tenant::where('slug', 'demo')->first(); - if (!$demoTenant) { + $demoTenant = Tenant::where('slug', 'demo-tenant')->first(); + if (! $demoTenant) { $this->command->info('Demo tenant not found, skipping EventTasksSeeder'); + return; } @@ -198,14 +200,17 @@ class EventTasksSeeder extends Seeder ]; // Difficulty rotation - $difficulties = ['easy','easy','medium','easy','medium','hard']; + $difficulties = ['easy', 'easy', 'medium', 'easy', 'medium', 'hard']; foreach (Emotion::all() as $emotion) { $name = is_array($emotion->name) ? ($emotion->name['de'] ?? array_values($emotion->name)[0]) : (string) $emotion->name; $list = $catalog[$name] ?? null; - if (!$list) continue; // skip unknown emotion labels + if (! $list) { + continue; + } // skip unknown emotion labels - $created = 0; $order = 1; + $created = 0; + $order = 1; foreach ($list as $i => $row) { [$deTitle, $deDesc, $enTitle, $enDesc] = $row; @@ -213,7 +218,11 @@ class EventTasksSeeder extends Seeder $exists = Task::where('emotion_id', $emotion->id) ->where('title->de', $deTitle) ->exists(); - if ($exists) { $order++; continue; } + if ($exists) { + $order++; + + continue; + } Task::create([ 'tenant_id' => $demoTenant->id, @@ -233,7 +242,7 @@ class EventTasksSeeder extends Seeder $i = 0; while ($created < 20 && $i < count($list)) { [$deTitle, $deDesc, $enTitle, $enDesc] = $list[$i]; - $suffix = ' #' . ($created + 1); + $suffix = ' #'.($created + 1); Task::create([ 'tenant_id' => $demoTenant->id, 'emotion_id' => $emotion->id, @@ -245,9 +254,9 @@ class EventTasksSeeder extends Seeder 'sort_order' => $order++, 'is_active' => true, ]); - $created++; $i++; + $created++; + $i++; } } } } - diff --git a/database/seeders/TasksSeeder.php b/database/seeders/TasksSeeder.php index fda2d0f..ac01201 100644 --- a/database/seeders/TasksSeeder.php +++ b/database/seeders/TasksSeeder.php @@ -6,6 +6,7 @@ use App\Models\Emotion; use App\Models\EventType; use App\Models\Task; use App\Models\TaskCollection; +use App\Models\Tenant; use Illuminate\Database\Seeder; use Illuminate\Support\Str; @@ -13,18 +14,13 @@ class TasksSeeder extends Seeder { public function run(): void { - // Create or get demo tenant - $demoTenant = \App\Models\Tenant::updateOrCreate( - ['slug' => 'demo'], - [ - 'name' => 'Demo Tenant', - 'domain' => null, - 'is_active' => true, - 'is_suspended' => false, - 'settings' => json_encode([]), - 'settings_updated_at' => null, - ] - ); + $demoTenant = Tenant::where('slug', 'demo-tenant')->first(); + + if (! $demoTenant) { + $this->command?->warn('Demo tenant not found, skipping TasksSeeder'); + + return; + } $seed = [ 'Liebe' => [ diff --git a/database/seeders/DemoLifecycleSeeder.php b/database/seeders/_DemoLifecycleSeeder.php similarity index 100% rename from database/seeders/DemoLifecycleSeeder.php rename to database/seeders/_DemoLifecycleSeeder.php diff --git a/database/seeders/WeddingTasksSeeder.php b/database/seeders/_WeddingTasksSeeder.php similarity index 95% rename from database/seeders/WeddingTasksSeeder.php rename to database/seeders/_WeddingTasksSeeder.php index bf58762..622c15c 100644 --- a/database/seeders/WeddingTasksSeeder.php +++ b/database/seeders/_WeddingTasksSeeder.php @@ -2,18 +2,22 @@ namespace Database\Seeders; +use App\Models\Emotion; +use App\Models\EventType; +use App\Models\Task; use Illuminate\Database\Seeder; -use App\Models\{Emotion, Task, EventType}; class WeddingTasksSeeder extends Seeder { public function run(): void { - $weddingType = EventType::where('slug','wedding')->first(); - if (!$weddingType) return; + $weddingType = EventType::where('slug', 'wedding')->first(); + if (! $weddingType) { + return; + } // Helper to resolve emotion by English name (more stable given encoding issues) - $by = fn(string $en) => Emotion::where('name->en', $en)->first(); + $by = fn (string $en) => Emotion::where('name->en', $en)->first(); $emLove = $by('Love'); $emJoy = $by('Joy'); @@ -88,7 +92,9 @@ class WeddingTasksSeeder extends Seeder $sort = 1; foreach ($tasks as [$emotion, $titleDe, $titleEn, $descDe, $descEn, $difficulty]) { - if (!$emotion) continue; + if (! $emotion) { + continue; + } Task::updateOrCreate([ 'emotion_id' => $emotion->id, 'title->de' => $titleDe, @@ -104,4 +110,3 @@ class WeddingTasksSeeder extends Seeder } } } - diff --git a/public/lang/de/auth.json b/public/lang/de/auth.json index ec45f18..04ec126 100644 --- a/public/lang/de/auth.json +++ b/public/lang/de/auth.json @@ -1,166 +1,55 @@ { + "login_failed": "Diese Anmeldedaten wurden nicht gefunden.", + "login_success": "Sie sind nun eingeloggt.", + "registration_failed": "Registrierung fehlgeschlagen.", + "registration_success": "Registrierung erfolgreich – bitte mit dem Kauf fortfahren.", + "already_logged_in": "Sie sind bereits eingeloggt.", + "failed_credentials": "Diese Anmeldedaten wurden nicht gefunden.", "header": { + "login": "Anmelden", + "register": "Registrieren", "home": "Startseite", - "how_it_works": "So geht's", "packages": "Pakete", "blog": "Blog", "occasions": { - "wedding": "Hochzeiten", - "birthday": "Geburtstage", - "corporate": "Firmenfeiern", - "confirmation": "Konfirmation/Jugendweihe", - "label": "Anlässe" + "wedding": "Hochzeit", + "birthday": "Geburtstag", + "corporate": "Firmenevent" }, - "contact": "Kontakt", - "login": "Anmelden", - "register": "Registrieren", - "cta": "Jetzt ausprobieren", - "utility": "Darstellung & Sprache", - "appearance": "Darstellung", - "appearance_light": "Hell", - "appearance_dark": "Dunkel", - "language": "Sprache" + "contact": "Kontakt" }, - "login_failed": "Ungültige E-Mail oder Passwort.", - "login_success": "Sie sind nun eingeloggt.", - "registration_failed": "Registrierung fehlgeschlagen.", - "registration_success": "Registrierung erfolgreich – fortfahren mit Kauf.", - "already_logged_in": "Sie sind bereits eingeloggt.", - "failed_credentials": "Falsche Anmeldedaten.", "login": { - "title": "Anmelden", - "description": "Melde dich mit deinem Fotospiel Konto an und begleite dein Event vom ersten Upload bis zum finalen Download.", - "identifier": "E-Mail oder Username", - "identifier_placeholder": "Geben Sie Ihre E-Mail oder Ihren Username ein", + "title": "Die Fotospiel.App", + "description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.", + "brand": "Die Fotospiel.App", + "logo_alt": "Logo Die Fotospiel.App", "username_or_email": "Username oder E-Mail", "email": "E-Mail-Adresse", - "email_placeholder": "Deine E-Mail-Adresse", + "email_placeholder": "ihre@email.de", "password": "Passwort", - "password_placeholder": "Geben Sie Ihr Passwort ein", - "forgot": "Passwort vergessen?", + "password_placeholder": "Ihr Passwort", "remember": "Angemeldet bleiben", + "forgot": "Passwort vergessen?", "submit": "Anmelden", - "no_account": "Noch keinen Account?", - "sign_up": "Jetzt registrieren", - "success_toast": "Login erfolgreich", - "unexpected_error": "Beim Login ist ein Fehler aufgetreten.", - "highlights": { - "moments": "Momente in Echtzeit teilen", - "moments_description": "Uploads landen sofort in der Event-Galerie – ohne App-Download.", - "branding": "Branding & Slideshows, die begeistern", - "branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.", - "privacy": "Sicherer Zugang über Tokens", - "privacy_description": "Eventzugänge bleiben geschützt – DSGVO-konform mit Join Tokens." - }, - "hero_tagline": "Event-Tech mit Herz", - "hero_heading": "Willkommen zurück bei Fotospiel", - "hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.", - "hero_footer": { - "headline": "Noch kein Account?", - "subline": "Entdecke unsere Packages und erlebe Fotospiel live.", - "cta": "Packages entdecken" - } + "oauth_divider": "oder", + "google_cta": "Mit Google anmelden", + "google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.", + "no_account": "Noch keinen Zugang?", + "sign_up": "Jetzt registrieren" }, "register": { "title": "Registrieren", "name": "Vollständiger Name", "username": "Username", - "username_placeholder": "Wählen Sie einen Username", "email": "E-Mail-Adresse", - "email_placeholder": "ihre@email.de", "password": "Passwort", - "password_placeholder": "Mindestens 8 Zeichen", "password_confirmation": "Passwort bestätigen", - "confirm_password": "Passwort bestätigen", - "confirm_password_placeholder": "Passwort wiederholen", "first_name": "Vorname", - "first_name_placeholder": "Max", "last_name": "Nachname", - "last_name_placeholder": "Mustermann", "address": "Adresse", - "address_placeholder": "Straße Hausnummer, PLZ Ort", "phone": "Telefonnummer", - "phone_placeholder": "+49 123 456789", "privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.", - "privacy_policy": "Datenschutzerklärung", - "submit": "Registrieren", - "success_toast": "Registrierung erfolgreich", - "validation_failed": "Bitte prüfen Sie Ihre Eingaben.", - "unexpected_error": "Registrierung nicht möglich.", - "errors_title": "Fehler bei der Registrierung", - "errors": { - "username": "Username", - "email": "E-Mail", - "password": "Passwort", - "password_confirmation": "Passwort-Bestätigung", - "first_name": "Vorname", - "last_name": "Nachname", - "address": "Adresse", - "phone": "Telefon", - "privacy_consent": "Datenschutz-Zustimmung" - } - }, - "common": { - "ui": { - "language_select": "Sprache auswählen" - } - }, - "settings": { - "profile": { - "title": "Profil-Einstellungen", - "section_title": "Profilinformationen", - "description": "Aktualisieren Sie Ihren Namen und Ihre E-Mail-Adresse", - "email": "E-Mail-Adresse", - "email_placeholder": "E-Mail-Adresse", - "username": "Username", - "username_placeholder": "Username", - "language": "Sprache", - "email_unverified": "Ihre E-Mail-Adresse ist nicht verifiziert.", - "resend_verification": "Klicken Sie hier, um die Verifizierungs-E-Mail erneut zu senden.", - "verification_sent": "Ein neuer Verifizierungslink wurde an Ihre E-Mail-Adresse gesendet." - }, - "password": { - "title": "Passwort-Einstellungen", - "section_title": "Passwort aktualisieren", - "description": "Stellen Sie sicher, dass Ihr Konto ein langes, zufälliges Passwort verwendet, um sicher zu bleiben", - "current": "Aktuelles Passwort", - "current_placeholder": "Aktuelles Passwort", - "new": "Neues Passwort", - "new_placeholder": "Neues Passwort", - "confirm": "Passwort bestätigen", - "confirm_placeholder": "Passwort bestätigen", - "save_button": "Passwort speichern" - } - }, - "reset": { - "password": "Passwort", - "password_placeholder": "Passwort", - "confirm_password": "Passwort bestätigen", - "confirm_password_placeholder": "Passwort bestätigen", - "email": "E-Mail", - "email_placeholder": "email@beispiel.de", - "title": "Passwort zurücksetzen", - "description": "Bitte geben Sie Ihr neues Passwort unten ein", - "submit": "Passwort zurücksetzen" - }, - "confirm": { - "password": "Passwort", - "password_placeholder": "Passwort", - "confirm": "Passwort bestätigen", - "title": "Passwort bestätigen", - "description": "Dies ist ein sicherer Bereich der Anwendung. Bitte bestätigen Sie Ihr Passwort, bevor Sie fortfahren.", - "submit": "Passwort bestätigen" - }, - "forgot": { - "email": "E-Mail-Adresse", - "email_placeholder": "email@beispiel.de", - "submit": "Passwort-Reset-Link per E-Mail senden", - "back": "Oder zurück zur Anmeldung" - }, - "delete_user": { - "password": "Passwort", - "password_placeholder": "Passwort", - "confirm_text": "Sobald Ihr Konto gelöscht ist, werden alle seine Ressourcen und Daten auch dauerhaft gelöscht. Bitte geben Sie Ihr Passwort ein, um zu bestätigen, dass Sie Ihr Konto dauerhaft löschen möchten." + "submit": "Registrieren" }, "verification": { "notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.", diff --git a/public/lang/en/auth.json b/public/lang/en/auth.json index b86cb7f..6b94be1 100644 --- a/public/lang/en/auth.json +++ b/public/lang/en/auth.json @@ -1,168 +1,58 @@ { - "header": { - "home": "Home", - "how_it_works": "How it works", - "packages": "Packages", - "blog": "Blog", - "occasions": { - "wedding": "Weddings", - "birthday": "Birthdays", - "corporate": "Corporate Events", - "confirmation": "Confirmation/Coming of Age", - "label": "Occasions" - }, - "contact": "Contact", - "login": "Login", - "register": "Register", - "cta": "Try now", - "utility": "Display & language", - "appearance": "Appearance", - "appearance_light": "Light", - "appearance_dark": "Dark", - "language": "Language" - }, "login_failed": "Invalid email or password.", "login_success": "You are now logged in.", "registration_failed": "Registration failed.", "registration_success": "Registration successful – proceed with purchase.", "already_logged_in": "You are already logged in.", - "failed_credentials": "Invalid credentials.", - "login": { - "title": "Login", - "description": "Sign in to your Fotospiel account and keep your event experience flowing from upload to download.", - "identifier": "Email or Username", - "identifier_placeholder": "Enter your email or username", - "email": "Email", - "email_placeholder": "Enter your email", - "password": "Password", - "password_placeholder": "Enter your password", - "forgot": "Forgot Password?", - "remember": "Remember me", - "submit": "Login", - "no_account": "No account yet?", - "sign_up": "Create one now", - "success_toast": "Login successful", - "unexpected_error": "Unable to log in right now.", - "highlights": { - "moments": "Share moments in real time", - "moments_description": "Uploads appear instantly in the event gallery – no app installation required.", - "branding": "Branding & slideshows that delight", - "branding_description": "Fine-tune slideshows, watermarks, and event tasks with a few clicks.", - "privacy": "Secure access via join tokens", - "privacy_description": "Keep events private and GDPR compliant with protected join tokens." + "failed_credentials": "Wrong credentials.", + "header": { + "login": "Login", + "register": "Register", + "home": "Home", + "packages": "Packages", + "blog": "Blog", + "occasions": { + "wedding": "Wedding", + "birthday": "Birthday", + "corporate": "Corporate Event" }, - "hero_tagline": "Event-tech with heart", - "hero_heading": "Welcome back to Fotospiel", - "hero_subheading": "Manage events, galleries, and guest lists in a lovingly crafted dashboard.", - "hero_footer": { - "headline": "Need an account?", - "subline": "Explore our packages and experience Fotospiel live.", - "cta": "Explore packages" - } + "contact": "Contact" + }, + "login": { + "title": "Die Fotospiel.App", + "description": "Sign in with your Fotospiel account to manage every event in one place.", + "brand": "Die Fotospiel.App", + "logo_alt": "Die Fotospiel.App logo", + "username_or_email": "Username or Email", + "email": "Email Address", + "email_placeholder": "your@email.com", + "password": "Password", + "password_placeholder": "Your password", + "remember": "Stay logged in", + "forgot": "Forgot password?", + "submit": "Login", + "oauth_divider": "or", + "google_cta": "Continue with Google", + "google_helper": "Use your Google account to access the event dashboard securely.", + "no_account": "Don't have access yet?", + "sign_up": "Create an account" }, "register": { "title": "Register", "name": "Full Name", "username": "Username", - "username_placeholder": "Choose a username", "email": "Email Address", - "email_placeholder": "your@email.com", "password": "Password", - "password_placeholder": "At least 8 characters", - "password_confirmation": "Confirm Password", - "confirm_password": "Confirm Password", - "confirm_password_placeholder": "Repeat password", + "password_confirmation": "Confirm password", "first_name": "First Name", - "first_name_placeholder": "John", "last_name": "Last Name", - "last_name_placeholder": "Doe", "address": "Address", - "address_placeholder": "Street Number, ZIP City", "phone": "Phone Number", - "phone_placeholder": "+1 123 456789", "privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.", - "privacy_policy": "Privacy Policy", - "submit": "Register", - "success_toast": "Registration successful", - "validation_failed": "Please check your input.", - "unexpected_error": "Registration not possible.", - "errors_title": "Registration errors", - "errors": { - "username": "Username", - "email": "Email", - "password": "Password", - "password_confirmation": "Password Confirmation", - "first_name": "First Name", - "last_name": "Last Name", - "address": "Address", - "phone": "Phone", - "privacy_consent": "Privacy Consent" - } - }, - "common": { - "ui": { - "language_select": "Select Language" - } - }, - "settings": { - "profile": { - "title": "Profile settings", - "section_title": "Profile information", - "description": "Update your name and email address", - "email": "Email address", - "email_placeholder": "Email address", - "username": "Username", - "username_placeholder": "Username", - "language": "Language", - "email_unverified": "Your email address is unverified.", - "resend_verification": "Click here to resend the verification email.", - "verification_sent": "A new verification link has been sent to your email address." - }, - "password": { - "title": "Password settings", - "section_title": "Update password", - "description": "Ensure your account is using a long, random password to stay secure", - "current": "Current password", - "current_placeholder": "Current password", - "new": "New password", - "new_placeholder": "New password", - "confirm": "Confirm password", - "confirm_placeholder": "Confirm password", - "save_button": "Save password" - } - }, - "reset": { - "password": "Password", - "password_placeholder": "Password", - "confirm_password": "Confirm password", - "confirm_password_placeholder": "Confirm password", - "email": "Email", - "email_placeholder": "email@example.com", - "title": "Reset password", - "description": "Please enter your new password below", - "submit": "Reset password" - }, - "confirm": { - "password": "Password", - "password_placeholder": "Password", - "confirm": "Confirm password", - "title": "Confirm your password", - "description": "This is a secure area of the application. Please confirm your password before continuing.", - "submit": "Confirm password" - }, - "forgot": { - "email": "Email address", - "email_placeholder": "email@example.com", - "submit": "Email password reset link", - "back": "Or, return to log in" - }, - "delete_user": { - "password": "Password", - "password_placeholder": "Password", - "confirm_text": "Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account." + "submit": "Register" }, "verification": { - "notice": "Please confirm your email address.", + "notice": "Please verify your email address.", "resend": "Resend email" } } diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 95aab35..097b99a 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -5,6 +5,24 @@ import i18n from './i18n'; type JsonValue = Record; +export type TenantAccountProfile = { + id: number; + name: string; + email: string; + preferred_locale: string | null; + email_verified: boolean; + email_verified_at: string | null; +}; + +export type UpdateTenantProfilePayload = { + name: string; + email: string; + preferred_locale?: string | null; + current_password?: string; + password?: string; + password_confirmation?: string; +}; + export type EventQrInviteLayout = { id: string; name: string; @@ -353,10 +371,16 @@ async function jsonOrThrow(response: Response, message: string, options: Json const errorCode = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.code === 'string' ? errorPayload.code : undefined; - const errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object' + let errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object' ? errorPayload.meta as Record : undefined; + if (!errorMeta && body && typeof body === 'object' && 'errors' in body && typeof body.errors === 'object') { + errorMeta = { + errors: body.errors as Record, + }; + } + if (!options.suppressToast) { emitApiErrorEvent({ message: errorMessage, status, code: errorCode, meta: errorMeta }); } @@ -1102,6 +1126,49 @@ export async function getNotificationPreferences(): Promise { + const response = await authorizedFetch('/api/v1/tenant/profile'); + const payload = await jsonOrThrow( + response, + i18n.t('settings.profile.errors.load', 'Profil konnte nicht geladen werden.'), + { suppressToast: true } + ); + + if (!payload.data) { + throw new Error('Profilantwort war leer.'); + } + + return payload.data; +} + +export async function updateTenantProfile(payload: UpdateTenantProfilePayload): Promise { + const response = await authorizedFetch('/api/v1/tenant/profile', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + }); + + const json = await jsonOrThrow( + response, + i18n.t('settings.profile.errors.update', 'Profil konnte nicht aktualisiert werden.'), + { suppressToast: true } + ); + + if (!json.data) { + throw new Error('Profilantwort war leer.'); + } + + return json.data; +} + export async function updateNotificationPreferences( preferences: NotificationPreferences ): Promise { diff --git a/resources/js/admin/auth/context.tsx b/resources/js/admin/auth/context.tsx index d3c8d49..a11ae88 100644 --- a/resources/js/admin/auth/context.tsx +++ b/resources/js/admin/auth/context.tsx @@ -8,7 +8,7 @@ import { registerAuthFailureHandler, startOAuthFlow, } from './tokens'; -import { ADMIN_LOGIN_PATH } from '../constants'; +import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants'; export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; @@ -86,17 +86,34 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, [handleAuthFailure, refreshProfile]); const login = React.useCallback((redirectPath?: string) => { - const target = redirectPath ?? window.location.pathname + window.location.search; + const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH; + const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`; startOAuthFlow(target); }, []); - const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => { - clearTokens(); - clearOAuthSession(); - setUser(null); - setStatus('unauthenticated'); - if (redirect) { - window.location.href = redirect; + const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => { + try { + const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content; + await fetch('/logout', { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}), + }, + credentials: 'same-origin', + }); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to notify backend about logout', error); + } + } finally { + clearTokens(); + clearOAuthSession(); + setUser(null); + setStatus('unauthenticated'); + if (redirect) { + window.location.href = redirect; + } } }, []); diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 3787c3f..5f88b58 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -4,10 +4,13 @@ export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`; export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH; export const ADMIN_HOME_PATH = adminPath('/dashboard'); +export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = adminPath('/events'); export const ADMIN_LOGIN_PATH = adminPath('/login'); +export const ADMIN_LOGIN_START_PATH = adminPath('/start'); export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback'); export const ADMIN_EVENTS_PATH = adminPath('/events'); export const ADMIN_SETTINGS_PATH = adminPath('/settings'); +export const ADMIN_PROFILE_PATH = adminPath('/settings/profile'); export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement'); export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string => `${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`; diff --git a/resources/js/admin/i18n/index.ts b/resources/js/admin/i18n/index.ts index ef7adab..2ff0e83 100644 --- a/resources/js/admin/i18n/index.ts +++ b/resources/js/admin/i18n/index.ts @@ -10,6 +10,8 @@ import deOnboarding from './locales/de/onboarding.json'; import enOnboarding from './locales/en/onboarding.json'; import deManagement from './locales/de/management.json'; import enManagement from './locales/en/management.json'; +import deSettings from './locales/de/settings.json'; +import enSettings from './locales/en/settings.json'; import deAuth from './locales/de/auth.json'; import enAuth from './locales/en/auth.json'; @@ -21,6 +23,7 @@ const resources = { dashboard: deDashboard, onboarding: deOnboarding, management: deManagement, + settings: deSettings, auth: deAuth, }, en: { @@ -28,6 +31,7 @@ const resources = { dashboard: enDashboard, onboarding: enOnboarding, management: enManagement, + settings: enSettings, auth: enAuth, }, } as const; diff --git a/resources/js/admin/i18n/locales/de/auth.json b/resources/js/admin/i18n/locales/de/auth.json index 5ebbb92..036b3a5 100644 --- a/resources/js/admin/i18n/locales/de/auth.json +++ b/resources/js/admin/i18n/locales/de/auth.json @@ -13,10 +13,25 @@ "lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.", "panel_title": "Melde dich an", "panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit OAuth 2.1 und klaren Rollenrechten.", + "actions_title": "Wähle deine Anmeldemethode", + "actions_copy": "Greife sicher per OAuth oder mit deinem Google-Konto auf das Tenant-Dashboard zu.", "cta": "Mit Fotospiel-Login fortfahren", + "google_cta": "Mit Google anmelden", + "open_account_login": "Konto-Login öffnen", "loading": "Bitte warten …", "oauth_error_title": "Login aktuell nicht möglich", "oauth_error": "Anmeldung fehlgeschlagen: {{message}}", + "oauth_errors": { + "login_required": "Bitte melde dich zuerst in deinem Fotospiel-Konto an.", + "invalid_request": "Die Login-Anfrage war ungültig. Bitte versuche es erneut.", + "invalid_client": "Die verknüpfte Tenant-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.", + "invalid_redirect": "Die angegebene Weiterleitungsadresse ist für diese App nicht hinterlegt.", + "invalid_scope": "Die App fordert Berechtigungen an, die nicht freigegeben sind.", + "tenant_mismatch": "Du hast keinen Zugriff auf den Tenant, der diese Anmeldung angefordert hat.", + "google_failed": "Die Anmeldung mit Google war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.", + "google_no_match": "Wir konnten dieses Google-Konto keinem Tenant-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an." + }, + "return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.", "support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.", "appearance_label": "Darstellung" } diff --git a/resources/js/admin/i18n/locales/de/settings.json b/resources/js/admin/i18n/locales/de/settings.json new file mode 100644 index 0000000..d84595d --- /dev/null +++ b/resources/js/admin/i18n/locales/de/settings.json @@ -0,0 +1,51 @@ +{ + "profile": { + "title": "Profil", + "subtitle": "Verwalte deine Kontodaten und Zugangsdaten.", + "sections": { + "account": { + "heading": "Account-Informationen", + "description": "Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an." + }, + "password": { + "heading": "Passwort ändern", + "description": "Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.", + "hint": "Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit." + } + }, + "fields": { + "name": "Anzeigename", + "email": "E-Mail-Adresse", + "locale": "Bevorzugte Sprache", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "passwordConfirmation": "Passwort bestätigen" + }, + "placeholders": { + "name": "z. B. Hochzeitsplanung Schmidt", + "locale": "Systemsprache verwenden" + }, + "locale": { + "auto": "Automatisch" + }, + "status": { + "emailVerified": "E-Mail bestätigt", + "emailNotVerified": "Bestätigung erforderlich", + "verifiedHint": "Bestätigt am {{date}}.", + "unverifiedHint": "Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung." + }, + "actions": { + "save": "Speichern", + "updatePassword": "Passwort aktualisieren", + "openProfile": "Profil bearbeiten" + }, + "toasts": { + "updated": "Profil wurde aktualisiert.", + "passwordChanged": "Passwort wurde aktualisiert." + }, + "errors": { + "load": "Profil konnte nicht geladen werden.", + "update": "Profil konnte nicht aktualisiert werden." + } + } +} diff --git a/resources/js/admin/i18n/locales/en/auth.json b/resources/js/admin/i18n/locales/en/auth.json index 4b4bc35..1a5dad3 100644 --- a/resources/js/admin/i18n/locales/en/auth.json +++ b/resources/js/admin/i18n/locales/en/auth.json @@ -13,10 +13,25 @@ "lead": "Use our secure OAuth login and land directly in the event dashboard.", "panel_title": "Sign in", "panel_copy": "Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.", + "actions_title": "Choose your sign-in method", + "actions_copy": "Access the tenant dashboard securely with OAuth or your Google account.", "cta": "Continue with Fotospiel login", + "google_cta": "Continue with Google", + "open_account_login": "Open account login", "loading": "Signing you in …", "oauth_error_title": "Login not possible right now", "oauth_error": "Sign-in failed: {{message}}", + "oauth_errors": { + "login_required": "Please sign in to your Fotospiel account before continuing.", + "invalid_request": "The login request was invalid. Please try again.", + "invalid_client": "We couldn’t find the linked tenant app. Please contact support if this persists.", + "invalid_redirect": "The redirect address is not registered for this app.", + "invalid_scope": "The app asked for permissions it cannot receive.", + "tenant_mismatch": "You don’t have access to the tenant that requested this login.", + "google_failed": "Google sign-in was not successful. Please try again or use another method.", + "google_no_match": "We couldn’t link this Google account to a tenant admin. Please sign in with Fotospiel credentials." + }, + "return_hint": "After signing in you’ll be brought back automatically.", "support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.", "appearance_label": "Appearance" } diff --git a/resources/js/admin/i18n/locales/en/settings.json b/resources/js/admin/i18n/locales/en/settings.json new file mode 100644 index 0000000..b361c34 --- /dev/null +++ b/resources/js/admin/i18n/locales/en/settings.json @@ -0,0 +1,51 @@ +{ + "profile": { + "title": "Profile", + "subtitle": "Manage your account details and credentials.", + "sections": { + "account": { + "heading": "Account information", + "description": "Update your name, email address, and interface language." + }, + "password": { + "heading": "Change password", + "description": "Choose a strong password to protect admin access.", + "hint": "Use at least 8 characters and mix letters and numbers for higher security." + } + }, + "fields": { + "name": "Display name", + "email": "Email address", + "locale": "Preferred language", + "currentPassword": "Current password", + "newPassword": "New password", + "passwordConfirmation": "Confirm password" + }, + "placeholders": { + "name": "e.g. Event Planning Smith", + "locale": "Use system language" + }, + "locale": { + "auto": "Automatic" + }, + "status": { + "emailVerified": "Email verified", + "emailNotVerified": "Verification required", + "verifiedHint": "Verified on {{date}}.", + "unverifiedHint": "We'll send another verification email when you change the address." + }, + "actions": { + "save": "Save", + "updatePassword": "Update password", + "openProfile": "Edit profile" + }, + "toasts": { + "updated": "Profile updated successfully.", + "passwordChanged": "Password updated." + }, + "errors": { + "load": "Unable to load your profile.", + "update": "Could not update your profile." + } + } +} diff --git a/resources/js/admin/lib/returnTo.ts b/resources/js/admin/lib/returnTo.ts new file mode 100644 index 0000000..48d6bf2 --- /dev/null +++ b/resources/js/admin/lib/returnTo.ts @@ -0,0 +1,172 @@ +import { ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH } from '../constants'; + +const LAST_DESTINATION_KEY = 'tenant.oauth.lastDestination'; + +function ensureLeadingSlash(target: string): string { + if (!target) { + return '/'; + } + + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(target)) { + return target; + } + + return target.startsWith('/') ? target : `/${target}`; +} + +function base64UrlEncode(value: string): string { + const encoder = new TextEncoder(); + const bytes = encoder.encode(value); + + let binary = ''; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); +} + +function base64UrlDecode(value: string): string | null { + try { + const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '='); + const normalized = padded.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(normalized); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + const decoder = new TextDecoder(); + + return decoder.decode(bytes); + } catch (error) { + console.warn('[Auth] Failed to decode return_to parameter', error); + return null; + } +} + +export function encodeReturnTo(value: string): string { + const trimmed = value.trim(); + if (trimmed === '') { + return ''; + } + + return base64UrlEncode(trimmed); +} + +export function decodeReturnTo(value: string | null): string | null { + if (!value) { + return null; + } + + return base64UrlDecode(value); +} + +export interface ReturnTargetResolution { + finalTarget: string; + encodedFinal: string; +} + +export function resolveReturnTarget(raw: string | null, fallback: string): ReturnTargetResolution { + const normalizedFallback = ensureLeadingSlash(fallback); + + if (!raw) { + const encoded = encodeReturnTo(normalizedFallback); + return { finalTarget: normalizedFallback, encodedFinal: encoded }; + } + + const decodedPrimary = decodeReturnTo(raw); + if (!decodedPrimary) { + const encoded = encodeReturnTo(normalizedFallback); + return { finalTarget: normalizedFallback, encodedFinal: encoded }; + } + + const normalizedPrimary = decodedPrimary.trim(); + + const wrapperPaths = [ADMIN_LOGIN_START_PATH, ADMIN_LOGIN_PATH]; + for (const wrapper of wrapperPaths) { + if (normalizedPrimary.startsWith(wrapper)) { + try { + const url = new URL(normalizedPrimary, window.location.origin); + const innerRaw = url.searchParams.get('return_to'); + if (!innerRaw) { + const encoded = encodeReturnTo(normalizedFallback); + return { finalTarget: normalizedFallback, encodedFinal: encoded }; + } + + return resolveReturnTarget(innerRaw, normalizedFallback); + } catch (error) { + console.warn('[Auth] Failed to parse return_to chain', error); + const encoded = encodeReturnTo(normalizedFallback); + return { finalTarget: normalizedFallback, encodedFinal: encoded }; + } + } + } + + const finalTarget = ensureLeadingSlash(normalizedPrimary); + const encodedFinal = encodeReturnTo(finalTarget); + + return { finalTarget, encodedFinal }; +} + +export function buildAdminOAuthStartPath(targetPath: string, encodedTarget?: string): string { + const sanitizedTarget = ensureLeadingSlash(targetPath); + const encoded = encodedTarget ?? encodeReturnTo(sanitizedTarget); + const url = new URL(ADMIN_LOGIN_START_PATH, window.location.origin); + url.searchParams.set('return_to', encoded); + + return `${url.pathname}${url.search}`; +} + +function resolveLocale(): string { + const raw = document.documentElement.lang || 'de'; + const normalized = raw.toLowerCase(); + + if (normalized.includes('-')) { + return normalized.split('-')[0] || 'de'; + } + + return normalized || 'de'; +} + +export function buildMarketingLoginUrl(returnPath: string): string { + const sanitizedPath = ensureLeadingSlash(returnPath); + const encoded = encodeReturnTo(sanitizedPath); + const locale = resolveLocale(); + const loginPath = `/${locale}/login`; + const url = new URL(loginPath, window.location.origin); + url.searchParams.set('return_to', encoded); + + return url.toString(); +} + +export function storeLastDestination(path: string): void { + if (typeof window === 'undefined') { + return; + } + + const sanitized = ensureLeadingSlash(path.trim()); + try { + window.sessionStorage.setItem(LAST_DESTINATION_KEY, sanitized); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to store last destination', error); + } + } +} + +export function consumeLastDestination(): string | null { + if (typeof window === 'undefined') { + return null; + } + + try { + const value = window.sessionStorage.getItem(LAST_DESTINATION_KEY); + if (value) { + window.sessionStorage.removeItem(LAST_DESTINATION_KEY); + return ensureLeadingSlash(value); + } + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to read last destination', error); + } + } + + return null; +} diff --git a/resources/js/admin/pages/AuthCallbackPage.tsx b/resources/js/admin/pages/AuthCallbackPage.tsx index 15a307f..8407747 100644 --- a/resources/js/admin/pages/AuthCallbackPage.tsx +++ b/resources/js/admin/pages/AuthCallbackPage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/context'; import { isAuthError } from '../auth/tokens'; -import { ADMIN_HOME_PATH } from '../constants'; +import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants'; export default function AuthCallbackPage() { const { completeLogin } = useAuth(); @@ -19,7 +19,7 @@ export default function AuthCallbackPage() { const params = new URLSearchParams(window.location.search); completeLogin(params) .then((redirectTo) => { - navigate(redirectTo ?? ADMIN_HOME_PATH, { replace: true }); + navigate(redirectTo ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH, { replace: true }); }) .catch((err) => { console.error('[Auth] Callback processing failed', err); @@ -40,4 +40,3 @@ export default function AuthCallbackPage() { ); } - diff --git a/resources/js/admin/pages/LoginPage.tsx b/resources/js/admin/pages/LoginPage.tsx index 718b15e..4a12161 100644 --- a/resources/js/admin/pages/LoginPage.tsx +++ b/resources/js/admin/pages/LoginPage.tsx @@ -1,204 +1,223 @@ import React from 'react'; import { Location, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Sparkles, ShieldCheck, Images, ArrowRight, Loader2 } from 'lucide-react'; +import { ArrowRight, Loader2 } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; -import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import AppLogoIcon from '@/components/app-logo-icon'; import { useAuth } from '../auth/context'; -import { ADMIN_HOME_PATH } from '../constants'; +import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants'; +import { buildAdminOAuthStartPath, buildMarketingLoginUrl, encodeReturnTo, resolveReturnTarget, storeLastDestination } from '../lib/returnTo'; interface LocationState { from?: Location; } -const featureIcons = [Sparkles, ShieldCheck, Images]; - export default function LoginPage(): JSX.Element { const { status, login } = useAuth(); const { t } = useTranslation('auth'); const location = useLocation(); const navigate = useNavigate(); const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]); + const oauthError = searchParams.get('error'); + const oauthErrorDescription = searchParams.get('error_description'); + const rawReturnTo = searchParams.get('return_to'); + const { finalTarget, encodedFinal } = React.useMemo( + () => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH), + [rawReturnTo] + ); + + const resolvedErrorMessage = React.useMemo(() => { + if (!oauthError) { + return null; + } + + const errorMap: Record = { + login_required: t('login.oauth_errors.login_required'), + invalid_request: t('login.oauth_errors.invalid_request'), + invalid_client: t('login.oauth_errors.invalid_client'), + invalid_redirect: t('login.oauth_errors.invalid_redirect'), + invalid_scope: t('login.oauth_errors.invalid_scope'), + tenant_mismatch: t('login.oauth_errors.tenant_mismatch'), + google_failed: t('login.oauth_errors.google_failed'), + google_no_match: t('login.oauth_errors.google_no_match'), + }; + + return errorMap[oauthError] ?? oauthErrorDescription ?? oauthError; + }, [oauthError, oauthErrorDescription, t]); React.useEffect(() => { if (status === 'authenticated') { - navigate(ADMIN_HOME_PATH, { replace: true }); + navigate(finalTarget, { replace: true }); } - }, [status, navigate]); + }, [finalTarget, navigate, status]); const redirectTarget = React.useMemo(() => { + if (finalTarget) { + return finalTarget; + } + const state = location.state as LocationState | null; if (state?.from) { const from = state.from; const search = from.search ?? ''; const hash = from.hash ?? ''; - return `${from.pathname}${search}${hash}`; + const path = `${from.pathname}${search}${hash}`; + + return path.startsWith('/') ? path : `/${path}`; } - return ADMIN_HOME_PATH; - }, [location.state]); - const featureList = React.useMemo(() => { - const raw = t('login.features', { returnObjects: true }) as unknown; - if (!Array.isArray(raw)) { - return [] as Array<{ text: string; Icon: typeof Sparkles }>; - } - return (raw as string[]).map((entry, index) => ({ - text: entry, - Icon: featureIcons[index % featureIcons.length], - })); - }, [t]); - - const heroTagline = t('login.hero_tagline', 'Stay in control, stay relaxed'); - const heroTitle = t('login.hero_title', 'Your cockpit for every Fotospiel event'); - const heroSubtitle = t('login.hero_subtitle', 'Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.'); - const panelTitle = t('login.panel_title', t('login.title', 'Event Admin')); - const leadCopy = t('login.lead', 'Use our secure OAuth login and land directly in the event dashboard.'); - const panelCopy = t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.'); - const supportCopy = t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help."); + return ADMIN_DEFAULT_AFTER_LOGIN_PATH; + }, [finalTarget, location.state]); + const shouldOpenAccountLogin = oauthError === 'login_required'; const isLoading = status === 'loading'; + const hasAutoStartedRef = React.useRef(false); + const oauthStartPath = React.useMemo( + () => buildAdminOAuthStartPath(redirectTarget, encodedFinal), + [encodedFinal, redirectTarget] + ); + const marketingLoginUrl = React.useMemo(() => buildMarketingLoginUrl(oauthStartPath), [oauthStartPath]); + + const hasRedirectedRef = React.useRef(false); + React.useEffect(() => { + if (!shouldOpenAccountLogin || hasRedirectedRef.current) { + return; + } + + hasRedirectedRef.current = true; + window.location.replace(marketingLoginUrl); + }, [marketingLoginUrl, shouldOpenAccountLogin]); + + React.useEffect(() => { + if (status !== 'unauthenticated' || oauthError || hasAutoStartedRef.current) { + return; + } + + hasAutoStartedRef.current = true; + storeLastDestination(redirectTarget); + login(redirectTarget); + }, [login, oauthError, redirectTarget, status]); + + const googleHref = React.useMemo(() => { + const target = new URL('/event-admin/auth/google', window.location.origin); + target.searchParams.set('return_to', encodeReturnTo(oauthStartPath)); + + return target.toString(); + }, [oauthStartPath]); return ( -
+
-
-
-
- - - -
-

{t('login.badge', 'Fotospiel Event Admin')}

-

Fotospiel

-
-
- -
+
+
+ + + +

{t('login.panel_title', t('login.title', 'Event Admin'))}

+

+ {t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.')} +

+
-
-
- - - {heroTagline} - -

{heroTitle}

-

{heroSubtitle}

+ {resolvedErrorMessage ? ( + + {t('login.oauth_error_title')} + {resolvedErrorMessage} + + ) : null} + +
+
+

{t('login.actions_title', 'Choose your sign-in method')}

+

+ {t('login.actions_copy', 'Access the tenant dashboard securely with OAuth or your Google account.')} +

-
-
-
-
-
+
+ -
-

{heroTitle}

-

{heroSubtitle}

-
- - {featureList.length ? ( -
    - {featureList.map(({ text, Icon }, index) => ( -
  • - - - - -

    {text}

    -
    -
  • - ))} -
- ) : null} -
- -

- - {leadCopy} -

-
- -
-
-
-
- - {t('login.badge', 'Fotospiel Event Admin')} - -
-

{panelTitle}

-

{panelCopy}

-
-
- - {oauthError ? ( - - {t('login.oauth_error_title')} - {t('login.oauth_error', { message: oauthError })} - - ) : null} - - - -
-

{leadCopy}

-

{supportCopy}

-
-
-
+
- {featureList.length ? ( -
- {featureList.map(({ text, Icon }, index) => ( -
- - - -

{text}

-
- ))} -
- ) : null} -
+

+ {t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.")} +

+
+ + {redirectTarget !== ADMIN_DEFAULT_AFTER_LOGIN_PATH ? ( +

{t('login.return_hint')}

+ ) : null}
); } +function GoogleIcon({ className }: { className?: string }): JSX.Element { + return ( + + + + + + + ); +} diff --git a/resources/js/admin/pages/LoginStartPage.tsx b/resources/js/admin/pages/LoginStartPage.tsx new file mode 100644 index 0000000..ab22ca7 --- /dev/null +++ b/resources/js/admin/pages/LoginStartPage.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; + +import { useAuth } from '../auth/context'; +import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants'; +import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget, storeLastDestination } from '../lib/returnTo'; + +export default function LoginStartPage(): JSX.Element { + const { status, login } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]); + + const rawReturnTo = searchParams.get('return_to'); + const { finalTarget, encodedFinal } = React.useMemo( + () => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH), + [rawReturnTo] + ); + + const [hasStarted, setHasStarted] = React.useState(false); + + React.useEffect(() => { + if (status === 'authenticated') { + navigate(finalTarget, { replace: true }); + return; + } + + if (hasStarted || status === 'loading') { + return; + } + + setHasStarted(true); + storeLastDestination(finalTarget); + login(finalTarget); + }, [finalTarget, hasStarted, login, navigate, status]); + + React.useEffect(() => { + if (status !== 'unauthenticated' || !hasStarted) { + return; + } + + const oauthStartPath = buildAdminOAuthStartPath(finalTarget, encodedFinal); + const marketingLoginUrl = buildMarketingLoginUrl(oauthStartPath); + window.location.replace(marketingLoginUrl); + }, [encodedFinal, finalTarget, hasStarted, status]); + + return ( +
+ +

Melde dich an …

+

Wir verbinden dich automatisch mit deinem Event-Dashboard.

+
+ ); +} diff --git a/resources/js/admin/pages/ProfilePage.tsx b/resources/js/admin/pages/ProfilePage.tsx new file mode 100644 index 0000000..3257ed1 --- /dev/null +++ b/resources/js/admin/pages/ProfilePage.tsx @@ -0,0 +1,421 @@ +import React from 'react'; +import { Loader2, ShieldCheck, ShieldX, Mail, User as UserIcon, Globe, Lock } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; + +import { AdminLayout } from '../components/AdminLayout'; +import { useAuth } from '../auth/context'; +import { + fetchTenantProfile, + updateTenantProfile, + type TenantAccountProfile, + type UpdateTenantProfilePayload, +} from '../api'; +import { getApiErrorMessage, isApiError } from '../lib/apiError'; + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +type FieldErrors = Record; + +function extractFieldErrors(error: unknown): FieldErrors { + if (isApiError(error) && error.meta && typeof error.meta.errors === 'object') { + const entries = error.meta.errors as Record; + const mapped: FieldErrors = {}; + + Object.entries(entries).forEach(([key, value]) => { + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') { + mapped[key] = value[0]; + } + }); + + return mapped; + } + + return {}; +} + +const DEFAULT_LOCALES = ['de', 'en']; +const AUTO_LOCALE_OPTION = '__auto__'; + +export default function ProfilePage(): JSX.Element { + const { t } = useTranslation(['settings', 'common']); + const { refreshProfile } = useAuth(); + + const [profile, setProfile] = React.useState(null); + const [loading, setLoading] = React.useState(true); + + const [infoForm, setInfoForm] = React.useState({ + name: '', + email: '', + preferred_locale: '', + }); + + const [passwordForm, setPasswordForm] = React.useState({ + current_password: '', + password: '', + password_confirmation: '', + }); + + const [infoErrors, setInfoErrors] = React.useState({}); + const [passwordErrors, setPasswordErrors] = React.useState({}); + const [savingInfo, setSavingInfo] = React.useState(false); + const [savingPassword, setSavingPassword] = React.useState(false); + + const availableLocales = React.useMemo(() => { + const candidates = new Set(DEFAULT_LOCALES); + if (typeof document !== 'undefined') { + const lang = document.documentElement.lang; + if (lang) { + const short = lang.toLowerCase().split('-')[0]; + candidates.add(short); + } + } + if (profile?.preferred_locale) { + candidates.add(profile.preferred_locale.toLowerCase()); + } + return Array.from(candidates).sort(); + }, [profile?.preferred_locale]); + + const selectedLocale = infoForm.preferred_locale && infoForm.preferred_locale !== '' ? infoForm.preferred_locale : AUTO_LOCALE_OPTION; + + React.useEffect(() => { + let cancelled = false; + + async function loadProfile(): Promise { + setLoading(true); + try { + const data = await fetchTenantProfile(); + if (cancelled) { + return; + } + setProfile(data); + setInfoForm({ + name: data.name ?? '', + email: data.email ?? '', + preferred_locale: data.preferred_locale ?? '', + }); + } catch (error) { + if (!cancelled) { + toast.error(getApiErrorMessage(error, t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.'))); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void loadProfile(); + + return () => { + cancelled = true; + }; + }, [t]); + + const handleInfoSubmit = React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + setInfoErrors({}); + setSavingInfo(true); + + const payload: UpdateTenantProfilePayload = { + name: infoForm.name, + email: infoForm.email, + preferred_locale: infoForm.preferred_locale || null, + }; + + try { + const updated = await updateTenantProfile(payload); + setProfile(updated); + toast.success(t('settings:profile.toasts.updated', 'Profil wurde aktualisiert.')); + setInfoForm({ + name: updated.name ?? '', + email: updated.email ?? '', + preferred_locale: updated.preferred_locale ?? '', + }); + setPasswordForm((prev) => ({ ...prev, current_password: '' })); + await refreshProfile(); + } catch (error) { + const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.')); + toast.error(message); + const fieldErrors = extractFieldErrors(error); + if (Object.keys(fieldErrors).length > 0) { + setInfoErrors(fieldErrors); + } + } finally { + setSavingInfo(false); + } + }, + [infoForm.email, infoForm.name, infoForm.preferred_locale, refreshProfile, t] + ); + + const handlePasswordSubmit = React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + setPasswordErrors({}); + setSavingPassword(true); + + const payload: UpdateTenantProfilePayload = { + name: infoForm.name, + email: infoForm.email, + preferred_locale: infoForm.preferred_locale || null, + current_password: passwordForm.current_password || undefined, + password: passwordForm.password || undefined, + password_confirmation: passwordForm.password_confirmation || undefined, + }; + + try { + const updated = await updateTenantProfile(payload); + setProfile(updated); + toast.success(t('settings:profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.')); + setPasswordForm({ current_password: '', password: '', password_confirmation: '' }); + await refreshProfile(); + } catch (error) { + const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.')); + toast.error(message); + const fieldErrors = extractFieldErrors(error); + if (Object.keys(fieldErrors).length > 0) { + setPasswordErrors(fieldErrors); + } + } finally { + setSavingPassword(false); + } + }, + [infoForm.email, infoForm.name, infoForm.preferred_locale, passwordForm, refreshProfile, t] + ); + + if (loading) { + return ( + +
+
+ + {t('common:loading', 'Wird geladen …')} +
+
+
+ ); + } + + if (!profile) { + return ( + +
+ {t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')} +
+
+ ); + } + + return ( + + + + + + {t('settings:profile.sections.account.heading', 'Account-Informationen')} + + + {t('settings:profile.sections.account.description', 'Passe Name, E-Mail und Sprache deiner Admin-Oberfläche an.')} + + + +
+
+
+ + setInfoForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder={t('settings:profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')} + /> + {infoErrors.name &&

{infoErrors.name}

} +
+ +
+ + setInfoForm((prev) => ({ ...prev, email: event.target.value }))} + placeholder="admin@example.com" + /> + {infoErrors.email &&

{infoErrors.email}

} +
+
+ +
+
+ + + {infoErrors.preferred_locale &&

{infoErrors.preferred_locale}

} +
+ +
+ +
+ {profile.email_verified + ? t('settings:profile.status.verifiedHint', 'Bestätigt am {{date}}.', { + date: profile.email_verified_at ? new Date(profile.email_verified_at).toLocaleString() : '', + }) + : t('settings:profile.status.unverifiedHint', 'Wir senden dir eine neue Bestätigung, sobald du die E-Mail änderst.')} +
+
+
+ +
+ + +
+
+
+
+ + + + + + {t('settings:profile.sections.password.heading', 'Passwort ändern')} + + + {t('settings:profile.sections.password.description', 'Wähle ein sicheres Passwort, um dein Admin-Konto zu schützen.')} + + + +
+
+
+ + setPasswordForm((prev) => ({ ...prev, current_password: event.target.value }))} + /> + {passwordErrors.current_password &&

{passwordErrors.current_password}

} +
+ +
+ + setPasswordForm((prev) => ({ ...prev, password: event.target.value }))} + /> + {passwordErrors.password &&

{passwordErrors.password}

} +
+ +
+ + setPasswordForm((prev) => ({ ...prev, password_confirmation: event.target.value }))} + /> + {passwordErrors.password_confirmation &&

{passwordErrors.password_confirmation}

} +
+
+ +
+ {t('settings:profile.sections.password.hint', 'Dein Passwort muss mindestens 8 Zeichen lang sein und eine Mischung aus Buchstaben und Zahlen enthalten.')} +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx index 1c5d7b1..11e62e8 100644 --- a/resources/js/admin/pages/SettingsPage.tsx +++ b/resources/js/admin/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AlertTriangle, LogOut, Palette } from 'lucide-react'; +import { AlertTriangle, LogOut, Palette, UserCog } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; @@ -10,7 +10,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { AdminLayout } from '../components/AdminLayout'; import { useAuth } from '../auth/context'; -import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH } from '../constants'; +import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants'; +import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo'; import { getNotificationPreferences, updateNotificationPreferences, @@ -33,7 +34,10 @@ export default function SettingsPage() { const [notificationMeta, setNotificationMeta] = React.useState(null); function handleLogout() { - logout({ redirect: ADMIN_LOGIN_PATH }); + const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH); + let marketingUrl = buildMarketingLoginUrl(targetPath); + marketingUrl += marketingUrl.includes('?') ? '&reset-auth=1' : '?reset-auth=1'; + logout({ redirect: marketingUrl }); } React.useEffect(() => { @@ -101,6 +105,9 @@ export default function SettingsPage() { + diff --git a/resources/js/admin/pages/WelcomeTeaserPage.tsx b/resources/js/admin/pages/WelcomeTeaserPage.tsx index 7add43f..c82db29 100644 --- a/resources/js/admin/pages/WelcomeTeaserPage.tsx +++ b/resources/js/admin/pages/WelcomeTeaserPage.tsx @@ -1,9 +1,7 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { ADMIN_HOME_PATH } from '../constants'; -import { useAuth } from '../auth/context'; +import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants'; +import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget } from '../lib/returnTo'; const highlights = [ { @@ -27,8 +25,23 @@ const highlights = [ ]; export default function WelcomeTeaserPage() { - const navigate = useNavigate(); - const { login } = useAuth(); + const [isRedirecting, setIsRedirecting] = React.useState(false); + + const handleLoginRedirect = React.useCallback(() => { + if (isRedirecting) { + return; + } + + setIsRedirecting(true); + + const params = new URLSearchParams(window.location.search); + const rawReturnTo = params.get('return_to'); + const { finalTarget, encodedFinal } = resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH); + const oauthStartPath = buildAdminOAuthStartPath(finalTarget, encodedFinal); + const marketingLoginUrl = buildMarketingLoginUrl(oauthStartPath); + + window.location.href = marketingLoginUrl; + }, [isRedirecting]); return (
@@ -48,9 +61,10 @@ export default function WelcomeTeaserPage() {
diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index c6d4982..cd909b9 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -18,14 +18,19 @@ import TaskCollectionsPage from './pages/TaskCollectionsPage'; import EmotionsPage from './pages/EmotionsPage'; import AuthCallbackPage from './pages/AuthCallbackPage'; import WelcomeTeaserPage from './pages/WelcomeTeaserPage'; +import LoginStartPage from './pages/LoginStartPage'; +import ProfilePage from './pages/ProfilePage'; import LogoutPage from './pages/LogoutPage'; import { useAuth } from './auth/context'; import { ADMIN_BASE_PATH, + ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_HOME_PATH, ADMIN_LOGIN_PATH, + ADMIN_LOGIN_START_PATH, ADMIN_PUBLIC_LANDING_PATH, } from './constants'; +import { consumeLastDestination } from './lib/returnTo'; import WelcomeLandingPage from './onboarding/pages/WelcomeLandingPage'; import WelcomePackagesPage from './onboarding/pages/WelcomePackagesPage'; import WelcomeEventSetupPage from './onboarding/pages/WelcomeEventSetupPage'; @@ -44,7 +49,7 @@ function RequireAuth() { } if (status === 'unauthenticated') { - return ; + return ; } return ; @@ -52,6 +57,7 @@ function RequireAuth() { function LandingGate() { const { status } = useAuth(); + const lastDestinationRef = React.useRef(null); if (status === 'loading') { return ( @@ -62,7 +68,11 @@ function LandingGate() { } if (status === 'authenticated') { - return ; + if (lastDestinationRef.current === null) { + lastDestinationRef.current = consumeLastDestination() ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH; + } + + return ; } return ; @@ -75,6 +85,7 @@ export const router = createBrowserRouter([ children: [ { index: true, element: }, { path: 'login', element: }, + { path: 'start', element: }, { path: 'logout', element: }, { path: 'auth/callback', element: }, { @@ -96,6 +107,7 @@ export const router = createBrowserRouter([ { path: 'emotions', element: }, { path: 'billing', element: }, { path: 'settings', element: }, + { path: 'settings/profile', element: }, { path: 'welcome', element: }, { path: 'welcome/packages', element: }, { path: 'welcome/summary', element: }, diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 3d27736..94eae5b 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -5,7 +5,7 @@ import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, Sid import { dashboard } from '@/routes'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; -import { BookOpen, Folder, LayoutGrid } from 'lucide-react'; +import { BookOpen, Folder, LayoutGrid, UserRound } from 'lucide-react'; import AppLogo from './app-logo'; const mainNavItems: NavItem[] = [ @@ -14,6 +14,11 @@ const mainNavItems: NavItem[] = [ href: dashboard(), icon: LayoutGrid, }, + { + title: 'Profil', + href: '/profile', + icon: UserRound, + }, ]; const footerNavItems: NavItem[] = [ diff --git a/resources/js/layouts/auth-layout.tsx b/resources/js/layouts/auth-layout.tsx index da141cc..9d77194 100644 --- a/resources/js/layouts/auth-layout.tsx +++ b/resources/js/layouts/auth-layout.tsx @@ -1,8 +1,17 @@ import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout'; -export default function AuthLayout({ children, title, description, ...props }: { children: React.ReactNode; title: string; description: string }) { +interface AuthLayoutProps { + children: React.ReactNode; + title: string; + description: string; + name?: string; + logoSrc?: string; + logoAlt?: string; +} + +export default function AuthLayout({ children, title, description, name, logoSrc, logoAlt }: AuthLayoutProps) { return ( - + {children} ); diff --git a/resources/js/layouts/auth/auth-simple-layout.tsx b/resources/js/layouts/auth/auth-simple-layout.tsx index 64861fb..5a386f2 100644 --- a/resources/js/layouts/auth/auth-simple-layout.tsx +++ b/resources/js/layouts/auth/auth-simple-layout.tsx @@ -1,6 +1,6 @@ import AppLogoIcon from '@/components/app-logo-icon'; import { Button } from '@/components/ui/button'; -import { home, packages } from '@/routes'; +import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { Link } from '@inertiajs/react'; import { Sparkles, Camera, ShieldCheck } from 'lucide-react'; import { type PropsWithChildren } from 'react'; @@ -10,10 +10,14 @@ interface AuthLayoutProps { name?: string; title?: string; description?: string; + logoSrc?: string; + logoAlt?: string; } -export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren) { +export default function AuthSimpleLayout({ children, title, description, name, logoSrc, logoAlt }: PropsWithChildren) { const { t } = useTranslation('auth'); + const { localizedPath } = useLocalizedRoutes(); + const brandLabel = name ?? 'Fotospiel'; const highlights = [ { @@ -85,7 +89,7 @@ export default function AuthSimpleLayout({ children, title, description }: Props

{t('login.hero_footer.subline', 'Entdecke unsere Packages und erlebe Fotospiel live.')}

@@ -94,12 +98,19 @@ export default function AuthSimpleLayout({ children, title, description }: Props
- - - - - Fotospiel - {title} + + {logoSrc ? ( + {logoAlt + ) : ( + + + + )} + {brandLabel}
diff --git a/resources/js/pages/Profile/Account.tsx b/resources/js/pages/Profile/Account.tsx deleted file mode 100644 index 1d60b24..0000000 --- a/resources/js/pages/Profile/Account.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { useForm } from '@inertiajs/react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; - -const ProfileAccount = () => { - const { data, setData, post, processing, errors } = useForm({ - name: '', - email: '', - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - post('/profile/account'); - }; - - return ( -
- - - Account bearbeiten - - -
-
- - setData('name', e.target.value)} /> - {errors.name &&

{errors.name}

} -
-
- - setData('email', e.target.value)} /> - {errors.email &&

{errors.email}

} -
- -
-
-
-
- ); -}; - -export default ProfileAccount; \ No newline at end of file diff --git a/resources/js/pages/Profile/Index.tsx b/resources/js/pages/Profile/Index.tsx index a1391a2..750cb7c 100644 --- a/resources/js/pages/Profile/Index.tsx +++ b/resources/js/pages/Profile/Index.tsx @@ -1,38 +1,375 @@ -import React from 'react'; -import { usePage } from '@inertiajs/react'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import Account from './Account'; -import Orders from './Orders'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useMemo, useRef } from 'react'; +import { Transition } from '@headlessui/react'; +import { Head, Form, Link, usePage } from '@inertiajs/react'; +import { CalendarClock, CheckCircle2, MailWarning, ReceiptText } from 'lucide-react'; -const ProfileIndex = () => { - const { user } = usePage().props as any; +import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; +import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController'; +import InputError from '@/components/input-error'; +import AppLayout from '@/layouts/app-layout'; +import { type BreadcrumbItem, type SharedData } from '@/types'; - return ( -
- - - Mein Profil - - -

Hallo, {user.name}!

-

Email: {user.email}

-
-
- - - Account - Bestellungen - - - - - - - - -
- ); +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Separator } from '@/components/ui/separator'; +import { send as resendVerificationRoute } from '@/routes/verification'; + +type ProfilePageProps = { + userData: { + id: number; + name: string; + email: string; + username?: string | null; + preferredLocale?: string | null; + emailVerifiedAt: string | null; + mustVerifyEmail: boolean; + }; + tenant: { + id: number; + name: string; + eventCreditsBalance: number | null; + subscriptionStatus: string | null; + subscriptionExpiresAt: string | null; + activePackage: { + name: string; + price: number | null; + expiresAt: string | null; + remainingEvents: number | null; + } | null; + } | null; + purchases: Array<{ + id: number; + packageName: string; + price: number | null; + purchasedAt: string | null; + type: string | null; + provider: string | null; + }>; }; -export default ProfileIndex; \ No newline at end of file +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Profil', + href: '/profile', + }, +]; + +export default function ProfileIndex() { + const { userData, tenant, purchases, supportedLocales, locale } = usePage().props; + + const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', { + day: '2-digit', + month: 'long', + year: 'numeric', + }), [locale]); + + const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 2, + }), [locale]); + + const registrationDate = useMemo(() => { + if (!userData.emailVerifiedAt) { + return null; + } + + try { + return dateFormatter.format(new Date(userData.emailVerifiedAt)); + } catch (error) { + return null; + } + }, [userData.emailVerifiedAt, dateFormatter]); + + const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—'); + const formatPrice = (price: number | null) => (price === null ? '—' : currencyFormatter.format(price)); + + const localeOptions = (supportedLocales ?? ['de', 'en']).map((value) => ({ + label: value.toUpperCase(), + value, + })); + + return ( + + + +
+ + +
+ Hallo, {userData.name || userData.email} +

+ Hier verwaltest du deine Zugangsdaten, sprichst mit uns in deiner Lieblingssprache und behältst alle Buchungen im Blick. +

+
+
+ + {userData.emailVerifiedAt ? : } + {userData.emailVerifiedAt ? 'E-Mail bestätigt' : 'Bestätigung ausstehend'} + + {registrationDate && ( +

Aktiv seit {registrationDate}

+ )} +
+
+ {!userData.emailVerifiedAt && userData.mustVerifyEmail && ( + + + +
+ E-Mail-Bestätigung ausstehend + + Bestätige deine E-Mail-Adresse, damit wir dich über Uploads, Rechnungen und Event-Updates informieren können. +
+ + Bestätigungslink erneut senden + +
+
+
+
+
+ )} +
+ +
+ + + + + + +
+ + + +
+ Abonnements & Pakete +

+ Hier findest du die wichtigsten Daten zu deinem aktuellen Paket und deinen letzten Buchungen. +

+
+ {tenant?.activePackage ? ( + + Läuft bis {formatDate(tenant.activePackage.expiresAt)} + + ) : ( + Kein aktives Paket + )} +
+ + {tenant?.activePackage ? ( +
+
+

{tenant.activePackage.name}

+

{tenant.eventCreditsBalance ?? 0} Credits verfügbar · {tenant.activePackage.remainingEvents ?? 0} Events inklusive

+
+
+
+ Status + {tenant.subscriptionStatus ?? 'aktiv'} +
+
+ Verlängerung + {formatDate(tenant.subscriptionExpiresAt)} +
+
+ Preis + {formatPrice(tenant.activePackage.price)} +
+
+
+ ) : ( + + + Du hast aktuell kein aktives Paket. Sichere dir jetzt Credits oder ein Komplettpaket, um neue Events zu planen. + + + )} + + + +
+
+ Letzte Buchungen +
+ {purchases.length === 0 ? ( +
+ Noch keine Buchungen vorhanden. Schaue im Dashboard vorbei, um passende Pakete zu finden. +
+ ) : ( + + + + Paket + Typ + Anbieter + Datum + Preis + + + + {purchases.map((purchase) => ( + + {purchase.packageName} + {purchase.type ?? '—'} + {purchase.provider ?? 'Checkout'} + {formatDate(purchase.purchasedAt)} + {formatPrice(purchase.price)} + + ))} + +
+ )} +
+
+
+
+
+ ); +} + +function AccountForm({ userData, localeOptions }: { userData: ProfilePageProps['userData']; localeOptions: Array<{ label: string; value: string }> }) { + return ( + <> + + Profilinformationen +

Aktualisiere deine Kontaktdaten und die Standardsprache für E-Mails und Oberfläche.

+
+ +
+ {({ processing, recentlySuccessful, errors }) => ( + <> +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+ + +

Gespeichert

+
+
+
+ + )} +
+
+ + ); +} + +function PasswordForm() { + const passwordInputRef = useRef(null); + const currentPasswordInputRef = useRef(null); + + return ( + <> + + Sicherheit & Passwort +

Vergebe ein starkes Passwort, um dein Konto bestmöglich zu schützen.

+
+ +
{ + if (errors.password) { + passwordInputRef.current?.focus(); + } + if (errors.current_password) { + currentPasswordInputRef.current?.focus(); + } + }} + className="space-y-6" + > + {({ errors, processing, recentlySuccessful }) => ( + <> +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+ + +

Aktualisiert

+
+
+
+ + )} +
+
+ + ); +} diff --git a/resources/js/pages/Profile/Orders.tsx b/resources/js/pages/Profile/Orders.tsx deleted file mode 100644 index 055329d..0000000 --- a/resources/js/pages/Profile/Orders.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import { usePage } from '@inertiajs/react'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { format } from 'date-fns'; - -interface Purchase { - id: number; - created_at: string; - package: { - name: string; - price: number; - }; - status: string; -} - -const ProfileOrders = () => { - const page = usePage<{ purchases?: Purchase[] }>(); - const purchases = page.props.purchases ?? []; - - return ( -
- - - Bestellungen - - - - - - Paket - Preis - Datum - Status - - - - {purchases.map((purchase) => ( - - {purchase.package.name} - {purchase.package.price} € - {format(new Date(purchase.created_at), 'dd.MM.yyyy')} - - - {purchase.status} - - - - ))} - -
- {purchases.length === 0 && ( -

Keine Bestellungen gefunden.

- )} -
-
-
- ); -}; - -export default ProfileOrders; diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index 793cb3d..0d73962 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -1,4 +1,5 @@ -import { FormEvent, useEffect, useState } from 'react'; +import { FormEvent, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; import { Head, useForm } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; import InputError from '@/components/input-error'; @@ -8,6 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import AuthLayout from '@/layouts/auth-layout'; +import AppLayout from '@/layouts/app/AppLayout'; import { register } from '@/routes'; import { request } from '@/routes/password'; import { LoaderCircle } from 'lucide-react'; @@ -19,12 +21,15 @@ interface LoginProps { export default function Login({ status, canResetPassword }: LoginProps) { const [hasTriedSubmit, setHasTriedSubmit] = useState(false); + const [rawReturnTo, setRawReturnTo] = useState(null); + const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); const { t } = useTranslation('auth'); const { data, setData, post, processing, errors, clearErrors } = useForm({ - email: '', + login: '', password: '', remember: false, + return_to: '', }); const submit = (e: FormEvent) => { @@ -38,6 +43,19 @@ export default function Login({ status, canResetPassword }: LoginProps) { const errorKeys = Object.keys(errors); const hasErrors = errorKeys.length > 0; + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const searchParams = new URLSearchParams(window.location.search); + setRawReturnTo(searchParams.get('return_to')); + }, []); + + useEffect(() => { + setData('return_to', rawReturnTo ?? ''); + }, [rawReturnTo, setData]); + useEffect(() => { if (!hasTriedSubmit) { return; @@ -56,42 +74,70 @@ export default function Login({ status, canResetPassword }: LoginProps) { } }, [errors, hasTriedSubmit]); + const googleHref = useMemo(() => { + if (!rawReturnTo) { + return '/event-admin/auth/google'; + } + + const params = new URLSearchParams({ + return_to: rawReturnTo, + }); + + return `/event-admin/auth/google?${params.toString()}`; + }, [rawReturnTo]); + + const handleGoogleLogin = () => { + if (typeof window === 'undefined') { + return; + } + + setIsRedirectingToGoogle(true); + window.location.href = googleHref; + }; + return ( - +
+
-
@@ -178,6 +224,32 @@ export default function Login({ status, canResetPassword }: LoginProps) { )}
+
+
+ + {t('login.oauth_divider', 'oder')} + +
+ + +

+ {t('login.google_helper', 'Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.')} +

+
+
{t('login.no_account')}{' '} ); } + +Login.layout = (page: ReactNode) => }>{page}; + +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 288f976..3d59f47 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -1,8 +1,85 @@ -import { PlaceholderPattern } from '@/components/ui/placeholder-pattern'; +import { useMemo, useState } from 'react'; +import { AlertTriangle, CalendarDays, Camera, ClipboardList, Package, Sparkles, TrendingUp, UserRound, Key } from 'lucide-react'; +import { Head, Link, router, usePage } from '@inertiajs/react'; + import AppLayout from '@/layouts/app-layout'; import { dashboard } from '@/routes'; -import { type BreadcrumbItem } from '@/types'; -import { Head } from '@inertiajs/react'; +import { edit as passwordSettings } from '@/routes/password'; +import profileRoutes from '@/routes/profile'; +import { send as resendVerificationRoute } from '@/routes/verification'; +import { type BreadcrumbItem, type SharedData } from '@/types'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Progress } from '@/components/ui/progress'; +import { Separator } from '@/components/ui/separator'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; + +type DashboardMetrics = { + total_events: number; + active_events: number; + published_events: number; + events_with_tasks: number; + upcoming_events: number; + new_photos: number; + task_progress: number; + credit_balance: number | null; + active_package: { + name: string; + expires_at: string | null; + remaining_events: number | null; + } | null; +}; + +type DashboardEvent = { + id: number; + name: string; + slug: string | null; + status: string | null; + isActive: boolean; + date: string | null; + photosCount: number; + tasksCount: number; + joinTokensCount: number; +}; + +type DashboardPurchase = { + id: number; + packageName: string; + price: number | null; + purchasedAt: string | null; + type: string | null; + provider: string | null; +}; + +type TenantSummary = { + id: number; + name: string; + eventCreditsBalance: number | null; + subscriptionStatus: string | null; + subscriptionExpiresAt: string | null; + activePackage: { + name: string; + price: number | null; + expiresAt: string | null; + remainingEvents: number | null; + } | null; +} | null; + +type DashboardPageProps = { + metrics: DashboardMetrics | null; + upcomingEvents: DashboardEvent[]; + recentPurchases: DashboardPurchase[]; + latestPurchase: DashboardPurchase | null; + tenant: TenantSummary; + emailVerification: { + mustVerify: boolean; + verified: boolean; + }; +}; const breadcrumbs: BreadcrumbItem[] = [ { @@ -12,24 +89,393 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function Dashboard() { + const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale } = usePage().props; + const [verificationSent, setVerificationSent] = useState(false); + const [sendingVerification, setSendingVerification] = useState(false); + + const needsEmailVerification = emailVerification.mustVerify && !emailVerification.verified; + + const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), [locale]); + + const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 2, + }), [locale]); + + const taskProgress = metrics?.task_progress ?? 0; + + const checklistItems = [ + { + key: 'verify-email', + title: 'E-Mail-Adresse bestätigen', + description: 'Bestätige deine Adresse, um Einladungen zu versenden und Benachrichtigungen zu erhalten.', + done: !needsEmailVerification, + }, + { + key: 'create-event', + title: 'Dein erstes Event erstellen', + description: 'Starte mit einem Event-Blueprint und passe Agenda, Uploadregeln und Branding an.', + done: (metrics?.total_events ?? 0) > 0, + }, + { + key: 'publish-event', + title: 'Event veröffentlichen', + description: 'Schalte dein Event frei, damit Gäste über den Link Fotos hochladen können.', + done: (metrics?.published_events ?? 0) > 0, + }, + { + key: 'invite-guests', + title: 'Gästelink teilen', + description: 'Nutze QR-Code oder Link, um Gäste einzuladen und erste Uploads zu sammeln.', + done: upcomingEvents.some((event) => event.joinTokensCount > 0), + }, + { + key: 'collect-photos', + title: 'Fotos sammeln', + description: 'Spare Zeit bei der Nachbereitung: neue Uploads erscheinen direkt in deinem Event.', + done: (metrics?.new_photos ?? 0) > 0, + }, + ]; + + const quickActions = [ + { + key: 'tenant-admin', + label: 'Event-Admin öffnen', + description: 'Detaillierte Eventverwaltung, Moderation und Live-Features.', + href: '/event-admin', + icon: Sparkles, + }, + { + key: 'profile', + label: 'Profil verwalten', + description: 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.', + href: profileRoutes.index().url, + icon: UserRound, + }, + { + key: 'password', + label: 'Passwort aktualisieren', + description: 'Sichere dein Konto mit einem aktuellen Passwort.', + href: passwordSettings().url, + icon: Key, + }, + { + key: 'packages', + label: 'Pakete entdecken', + description: 'Mehr Events oder Speicher buchen – du bleibst flexibel.', + href: `/${locale ?? 'de'}/packages`, + icon: Package, + }, + ]; + + const stats = [ + { + key: 'active-events', + label: 'Aktive Events', + value: metrics?.active_events ?? 0, + description: metrics?.active_events + ? 'Events sind live und für Gäste sichtbar.' + : 'Noch kein Event veröffentlicht – starte heute!', + icon: CalendarDays, + }, + { + key: 'upcoming-events', + label: 'Bevorstehende Events', + value: metrics?.upcoming_events ?? 0, + description: metrics?.upcoming_events + ? 'Planung läuft – behalte Checklisten und Aufgaben im Blick.' + : 'Lass dich vom Assistenten beim Planen unterstützen.', + icon: TrendingUp, + }, + { + key: 'new-photos', + label: 'Neue Fotos (7 Tage)', + value: metrics?.new_photos ?? 0, + description: metrics && metrics.new_photos > 0 + ? 'Frisch eingetroffene Erinnerungen deiner Gäste.' + : 'Sammle erste Uploads über QR-Code oder Direktlink.', + icon: Camera, + }, + { + key: 'credit-balance', + label: 'Event Credits', + value: tenant?.eventCreditsBalance ?? 0, + description: tenant?.eventCreditsBalance + ? 'Verfügbare Credits für neue Events.' + : 'Buche Pakete oder Credits, um weitere Events zu planen.', + icon: Package, + extra: metrics?.active_package?.remaining_events ?? null, + }, + { + key: 'task-progress', + label: 'Event-Checkliste', + value: `${taskProgress}%`, + description: taskProgress > 0 + ? 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.' + : 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.', + icon: ClipboardList, + }, + ]; + + const handleResendVerification = () => { + setSendingVerification(true); + setVerificationSent(false); + + router.post(resendVerificationRoute(), {}, { + preserveScroll: true, + onSuccess: () => setVerificationSent(true), + onFinish: () => setSendingVerification(false), + }); + }; + + const renderPrice = (price: number | null) => { + if (price === null) { + return '—'; + } + + try { + return currencyFormatter.format(price); + } catch (error) { + return `${price.toFixed(2)} €`; + } + }; + + const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—'); + return ( -
-
-
- -
-
- -
-
- + +
+ {needsEmailVerification && ( + + +
+
+ Bitte bestätige deine E-Mail-Adresse + + Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist. + Prüfe dein Postfach oder fordere einen neuen Link an. + +
+
+ + {verificationSent && Wir haben dir gerade einen neuen Bestätigungslink geschickt.} +
+
+
+ )} + +
+ {stats.map((stat) => ( + + +
+

{stat.label}

+
{stat.value}
+
+ + + +
+ +

{stat.description}

+ {stat.key === 'credit-balance' && stat.extra !== null && ( +
{stat.extra} weitere Events im aktuellen Paket enthalten.
+ )} + {stat.key === 'task-progress' && ( +
+ + {taskProgress}% deiner Event-Checkliste erledigt. +
+ )} +
+
+ ))} +
+ +
+ + +
+ Bevorstehende Events +

Status, Uploads und Aufgaben deiner nächsten Events im Überblick.

+
+ 0 ? 'secondary' : 'outline'}> + {upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'} + +
+ + {upcomingEvents.length === 0 && ( +
+

+ Plane dein erstes Event und begleite den gesamten Ablauf – vom Briefing bis zur Nachbereitung – direkt hier im Dashboard. +

+
+ )} + + {upcomingEvents.map((event) => ( +
+
+
+

{event.name}

+

+ {formatDate(event.date)} · {event.status === 'published' || event.isActive ? 'Live' : 'In Vorbereitung'} +

+
+
+ {event.photosCount} Fotos + {event.tasksCount} Aufgaben + {event.joinTokensCount} Links +
+
+
+ ))} +
+
+ +
+ + + Nächstes Paket & Credits +

Behalte Laufzeiten und verfügbaren Umfang stets im Blick.

+
+ + {tenant?.activePackage ? ( +
+
+ {tenant.activePackage.name} + {tenant.activePackage.remainingEvents ?? 0} Events übrig +
+
+
+ Läuft ab + {formatDate(tenant.activePackage.expiresAt)} +
+
+ Preis + {renderPrice(tenant.activePackage.price)} +
+
+ {latestPurchase && ( +
+ Zuletzt gebucht am {formatDate(latestPurchase.purchasedAt)} via {latestPurchase.provider?.toUpperCase() ?? 'Checkout'}. +
+ )} +
+ ) : ( +
+ Noch kein aktives Paket. Jetzt Paket auswählen und direkt Events planen. +
+ )} + +
+ Event Credits insgesamt + {tenant?.eventCreditsBalance ?? 0} +
+
+ Credits werden bei neuen Events automatisch verbraucht. Zusätzliche Kontingente kannst du jederzeit buchen. +
+
+
+ + + + Dein Start in 5 Schritten +

Folge den wichtigsten Schritten, um dein Event reibungslos aufzusetzen.

+
+ +
+ {checklistItems.map((item) => ( +
+ +
+

{item.title}

+

{item.description}

+
+
+ ))} +
+
+
-
- -
+ + + +
+ Aktuelle Buchungen +

Verfolge deine gebuchten Pakete und Erweiterungen.

+
+ {recentPurchases.length} Einträge +
+ + {recentPurchases.length === 0 ? ( +
+ Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent. +
+ ) : ( + + + + Paket + Typ + Anbieter + Datum + Preis + + + + {recentPurchases.map((purchase) => ( + + {purchase.packageName} + {purchase.type ?? '—'} + {purchase.provider ?? 'Checkout'} + {formatDate(purchase.purchasedAt)} + {renderPrice(purchase.price)} + + ))} + +
+ )} +
+
+ + + + Schnellzugriff +

Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.

+
+ + {quickActions.map((action) => ( +
+
+ + + +
+

{action.label}

+

{action.description}

+
+
+
+ +
+
+ ))} +
+
); diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 3324fef..5e928b6 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -28,6 +28,7 @@ export interface SharedData { auth: Auth; sidebarOpen: boolean; supportedLocales?: string[]; + locale?: string; security?: { csp?: { scriptNonce?: string; diff --git a/resources/lang/de/auth.json b/resources/lang/de/auth.json index cd4d020..04ec126 100644 --- a/resources/lang/de/auth.json +++ b/resources/lang/de/auth.json @@ -2,7 +2,7 @@ "login_failed": "Diese Anmeldedaten wurden nicht gefunden.", "login_success": "Sie sind nun eingeloggt.", "registration_failed": "Registrierung fehlgeschlagen.", - "registration_success": "Registrierung erfolgreich – bitte mit dem Kauf fortfahren.", + "registration_success": "Registrierung erfolgreich – bitte mit dem Kauf fortfahren.", "already_logged_in": "Sie sind bereits eingeloggt.", "failed_credentials": "Diese Anmeldedaten wurden nicht gefunden.", "header": { @@ -19,7 +19,10 @@ "contact": "Kontakt" }, "login": { - "title": "Anmelden", + "title": "Die Fotospiel.App", + "description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.", + "brand": "Die Fotospiel.App", + "logo_alt": "Logo Die Fotospiel.App", "username_or_email": "Username oder E-Mail", "email": "E-Mail-Adresse", "email_placeholder": "ihre@email.de", @@ -27,24 +30,29 @@ "password_placeholder": "Ihr Passwort", "remember": "Angemeldet bleiben", "forgot": "Passwort vergessen?", - "submit": "Anmelden" + "submit": "Anmelden", + "oauth_divider": "oder", + "google_cta": "Mit Google anmelden", + "google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.", + "no_account": "Noch keinen Zugang?", + "sign_up": "Jetzt registrieren" }, "register": { "title": "Registrieren", - "name": "Vollständiger Name", + "name": "Vollständiger Name", "username": "Username", "email": "E-Mail-Adresse", "password": "Passwort", - "password_confirmation": "Passwort bestätigen", + "password_confirmation": "Passwort bestätigen", "first_name": "Vorname", "last_name": "Nachname", "address": "Adresse", "phone": "Telefonnummer", - "privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.", + "privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.", "submit": "Registrieren" }, "verification": { - "notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.", + "notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.", "resend": "E-Mail erneut senden" } } diff --git a/resources/lang/en/auth.json b/resources/lang/en/auth.json index 43bc1a3..6b94be1 100644 --- a/resources/lang/en/auth.json +++ b/resources/lang/en/auth.json @@ -19,7 +19,10 @@ "contact": "Contact" }, "login": { - "title": "Login", + "title": "Die Fotospiel.App", + "description": "Sign in with your Fotospiel account to manage every event in one place.", + "brand": "Die Fotospiel.App", + "logo_alt": "Die Fotospiel.App logo", "username_or_email": "Username or Email", "email": "Email Address", "email_placeholder": "your@email.com", @@ -27,7 +30,12 @@ "password_placeholder": "Your password", "remember": "Stay logged in", "forgot": "Forgot password?", - "submit": "Login" + "submit": "Login", + "oauth_divider": "or", + "google_cta": "Continue with Google", + "google_helper": "Use your Google account to access the event dashboard securely.", + "no_account": "Don't have access yet?", + "sign_up": "Create an account" }, "register": { "title": "Register", @@ -47,4 +55,4 @@ "notice": "Please verify your email address.", "resend": "Resend email" } -} \ No newline at end of file +} diff --git a/routes/api.php b/routes/api.php index 981f4dc..b30e0db 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController; use App\Http\Controllers\Api\Tenant\EventTypeController; use App\Http\Controllers\Api\Tenant\NotificationLogController; use App\Http\Controllers\Api\Tenant\PhotoController; +use App\Http\Controllers\Api\Tenant\ProfileController; use App\Http\Controllers\Api\Tenant\SettingsController; use App\Http\Controllers\Api\Tenant\TaskCollectionController; use App\Http\Controllers\Api\Tenant\TaskController; @@ -19,6 +20,9 @@ use App\Http\Controllers\Api\TenantPackageController; use App\Http\Controllers\OAuthController; use App\Http\Controllers\RevenueCatWebhookController; use App\Http\Controllers\Tenant\CreditController; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; +use Illuminate\Cookie\Middleware\EncryptCookies; +use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; Route::prefix('v1')->name('api.v1.')->group(function () { @@ -26,7 +30,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->middleware('throttle:60,1') ->name('webhooks.revenuecat'); - Route::middleware('throttle:oauth')->group(function () { + Route::middleware([EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, 'throttle:oauth'])->group(function () { Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->name('oauth.authorize'); Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token'); }); @@ -57,6 +61,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () { }); Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { + Route::get('profile', [ProfileController::class, 'show'])->name('tenant.profile.show'); + Route::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update'); Route::get('me', [OAuthController::class, 'me'])->name('tenant.me'); Route::get('dashboard', DashboardController::class)->name('tenant.dashboard'); Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index'); diff --git a/routes/web.php b/routes/web.php index e541973..c851fe3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,12 +4,15 @@ use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\Auth\RegisteredUserController; use App\Http\Controllers\CheckoutController; use App\Http\Controllers\CheckoutGoogleController; +use App\Http\Controllers\DashboardController; use App\Http\Controllers\LegalPageController; use App\Http\Controllers\LocaleController; use App\Http\Controllers\MarketingController; use App\Http\Controllers\PaddleCheckoutController; use App\Http\Controllers\PaddleWebhookController; +use App\Http\Controllers\ProfileController; use App\Http\Controllers\Tenant\EventPhotoArchiveController; +use App\Http\Controllers\TenantAdminGoogleController; use App\Models\Package; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -227,15 +230,23 @@ Route::get('/buy/{packageId}', function (Request $request, int $packageId) use ( return redirect("/{$locale}/buy/{$packageId}", 301); }); -Route::get('/dashboard', function () { - return Inertia::render('dashboard'); -})->middleware(['auth', 'verified'])->name('dashboard'); +Route::get('/dashboard', DashboardController::class) + ->middleware(['auth']) + ->name('dashboard'); +Route::middleware('auth')->group(function () { + Route::get('/profile', [ProfileController::class, 'index']) + ->name('profile.index'); +}); Route::prefix('event-admin')->group(function () { $renderAdmin = fn () => view('admin'); Route::get('/auth/callback', $renderAdmin)->name('tenant.admin.auth.callback'); Route::get('/login', $renderAdmin)->name('tenant.admin.login'); Route::get('/logout', $renderAdmin)->name('tenant.admin.logout'); + Route::get('/auth/google', [TenantAdminGoogleController::class, 'redirect']) + ->name('tenant.admin.google.redirect'); + Route::get('/auth/google/callback', [TenantAdminGoogleController::class, 'callback']) + ->name('tenant.admin.google.callback'); Route::get('/{view?}', $renderAdmin) ->where('view', '.*') ->name('tenant.admin.app'); diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php index b896edc..e8a83df 100644 --- a/tests/Feature/Auth/LoginTest.php +++ b/tests/Feature/Auth/LoginTest.php @@ -3,11 +3,9 @@ namespace Tests\Feature\Auth; use App\Models\User; -use App\Notifications\VerifyEmail; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Notification; -use Tests\TestCase; use Illuminate\Support\Facades\Auth; +use Tests\TestCase; class LoginTest extends TestCase { @@ -27,7 +25,8 @@ class LoginTest extends TestCase ]); $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); + $expectedDefault = rtrim(route('tenant.admin.app', absolute: false), '/').'/events'; + $response->assertRedirect($expectedDefault); $this->assertEquals('valid@example.com', Auth::user()->email); } @@ -45,7 +44,8 @@ class LoginTest extends TestCase ]); $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); + $expectedDefault = rtrim(route('tenant.admin.app', absolute: false), '/').'/events'; + $response->assertRedirect($expectedDefault); $this->assertEquals('validuser', Auth::user()->username); } @@ -82,10 +82,32 @@ class LoginTest extends TestCase ]); $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); + $expected = rtrim(route('tenant.admin.app', absolute: false), '/').'/events'; + $response->assertRedirect($expected); $response->assertSessionHas('success', 'Sie sind nun eingeloggt.'); } + public function test_login_honors_return_to_parameter() + { + $user = User::factory()->create([ + 'email' => 'return@example.com', + 'password' => bcrypt('password'), + 'email_verified_at' => now(), + ]); + + $target = route('tenant.admin.app', absolute: false); + $encoded = rtrim(strtr(base64_encode($target), '+/', '-_'), '='); + + $response = $this->post(route('login.store'), [ + 'login' => 'return@example.com', + 'password' => 'password', + 'return_to' => $encoded, + ]); + + $this->assertAuthenticated(); + $response->assertRedirect($target); + } + public function test_login_redirects_unverified_user_to_verification_notice() { $user = User::factory()->create([ diff --git a/tests/Feature/Auth/TenantAdminGoogleControllerTest.php b/tests/Feature/Auth/TenantAdminGoogleControllerTest.php new file mode 100644 index 0000000..8678adb --- /dev/null +++ b/tests/Feature/Auth/TenantAdminGoogleControllerTest.php @@ -0,0 +1,102 @@ +once()->with('google')->andReturn($driver); + $driver->shouldReceive('scopes')->once()->with(['openid', 'profile', 'email'])->andReturnSelf(); + $driver->shouldReceive('with')->once()->with(['prompt' => 'select_account'])->andReturnSelf(); + $driver->shouldReceive('redirect')->once()->andReturn(new RedirectResponse('https://accounts.google.com')); + + $encodedReturn = rtrim(strtr(base64_encode('http://localhost/test'), '+/', '-_'), '='); + + $response = $this->get('/event-admin/auth/google?return_to='.$encodedReturn); + + $response->assertRedirect('https://accounts.google.com'); + $this->assertSame($encodedReturn, session('tenant_oauth_return_to')); + } + + public function test_callback_logs_in_tenant_admin_and_redirects_to_encoded_target(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + ]); + + $socialiteUser = tap(new SocialiteUser)->map([ + 'id' => 'google-id-123', + 'name' => 'Google Tenant Admin', + 'email' => $user->email, + ]); + + $driver = Mockery::mock(); + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver); + $driver->shouldReceive('user')->once()->andReturn($socialiteUser); + + $targetUrl = 'http://localhost:8000/api/v1/oauth/authorize?foo=bar'; + $encodedReturn = rtrim(strtr(base64_encode($targetUrl), '+/', '-_'), '='); + + $this->withSession([ + 'tenant_oauth_return_to' => $encodedReturn, + ]); + + $response = $this->get('/event-admin/auth/google/callback'); + + $response->assertRedirect($targetUrl); + $this->assertAuthenticatedAs($user); + } + + public function test_callback_redirects_back_when_user_not_found(): void + { + $socialiteUser = tap(new SocialiteUser)->map([ + 'id' => 'missing-user', + 'name' => 'Unknown User', + 'email' => 'unknown@example.com', + ]); + + $driver = Mockery::mock(); + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver); + $driver->shouldReceive('user')->once()->andReturn($socialiteUser); + + $response = $this->get('/event-admin/auth/google/callback'); + + $response->assertRedirect(); + $this->assertStringContainsString('error=google_no_match', $response->headers->get('Location')); + $this->assertFalse(Auth::check()); + } + + public function test_callback_handles_socialite_failure(): void + { + $driver = Mockery::mock(); + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver); + $driver->shouldReceive('user')->once()->andThrow(new \RuntimeException('boom')); + + $response = $this->get('/event-admin/auth/google/callback'); + + $response->assertRedirect(); + $this->assertStringContainsString('error=google_failed', $response->headers->get('Location')); + } +} diff --git a/tests/Feature/Dashboard/DashboardPageTest.php b/tests/Feature/Dashboard/DashboardPageTest.php new file mode 100644 index 0000000..7fe4af7 --- /dev/null +++ b/tests/Feature/Dashboard/DashboardPageTest.php @@ -0,0 +1,104 @@ +create([ + 'event_credits_balance' => 4, + ]); + + $package = Package::factory()->reseller()->create([ + 'name_translations' => [ + 'de' => 'Premium Paket', + 'en' => 'Premium Package', + ], + 'max_events_per_year' => 10, + ]); + + TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'price' => 149.00, + 'purchased_at' => now()->subDay(), + 'expires_at' => now()->addMonth(), + 'used_events' => 1, + 'active' => true, + ]); + + $user = User::factory() + ->unverified() + ->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + ]); + + $event = Event::factory()->for($tenant)->create([ + 'status' => 'published', + 'is_active' => true, + 'date' => now()->addDays(7), + 'name' => ['de' => 'Sommerfest', 'en' => 'Summer Party'], + ]); + + $task = Task::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_completed' => true, + ]); + + $event->tasks()->attach($task); + + Photo::factory()->for($event)->create([ + 'tenant_id' => $tenant->id, + 'created_at' => now()->subDay(), + ]); + + PackagePurchase::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'price' => 149.00, + 'type' => 'reseller_subscription', + 'provider' => 'paddle', + 'purchased_at' => now()->subDay(), + ]); + + $this->actingAs($user); + + $response = $this->get(route('dashboard')); + + $response->assertStatus(200) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('dashboard') + ->has('metrics', fn (AssertableInertia $metrics) => $metrics + ->where('active_events', 1) + ->where('total_events', 1) + ->where('task_progress', 100) + ->where('upcoming_events', 1) + ->where('new_photos', 1) + ->where('credit_balance', 4) + ->etc() + ) + ->where('tenant.activePackage.name', 'Premium Paket') + ->where('tenant.activePackage.remainingEvents', 9) + ->has('upcomingEvents', 1) + ->has('recentPurchases', 1) + ->where('emailVerification.mustVerify', true) + ->where('emailVerification.verified', false) + ); + } +} diff --git a/tests/Feature/OAuth/AuthorizeTest.php b/tests/Feature/OAuth/AuthorizeTest.php new file mode 100644 index 0000000..c30642c --- /dev/null +++ b/tests/Feature/OAuth/AuthorizeTest.php @@ -0,0 +1,204 @@ +create(); + $client = $this->createClientForTenant($tenant); + $query = $this->buildAuthorizeQuery($client); + $fullUrl = url('/api/v1/oauth/authorize?'.http_build_query($query)); + + $response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query)); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + $this->assertNotNull($location); + + $this->assertStringStartsWith(route('tenant.admin.login'), $location); + + $parsed = parse_url($location); + $actualQuery = []; + parse_str($parsed['query'] ?? '', $actualQuery); + + $this->assertSame('login_required', $actualQuery['error'] ?? null); + $this->assertSame('Please sign in to continue.', $actualQuery['error_description'] ?? null); + $this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null); + + $this->assertIntendedUrlMatches($query); + } + + public function test_authorize_returns_json_payload_for_ajax_guests(): void + { + $tenant = Tenant::factory()->create(); + $client = $this->createClientForTenant($tenant); + $query = $this->buildAuthorizeQuery($client); + + $response = $this->withHeaders(['Accept' => 'application/json']) + ->get('/api/v1/oauth/authorize?'.http_build_query($query)); + + $response->assertStatus(401) + ->assertJson([ + 'error' => 'login_required', + 'error_description' => 'Please sign in to continue.', + ]); + + $this->assertIntendedUrlMatches($query); + } + + public function test_authorize_rejects_when_user_cannot_access_client_tenant(): void + { + $homeTenant = Tenant::factory()->create(); + $otherTenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'tenant_id' => $homeTenant->id, + 'role' => 'tenant_admin', + ]); + + $client = $this->createClientForTenant($otherTenant); + + $this->actingAs($user); + + $query = $this->buildAuthorizeQuery($client); + $response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query)); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + $this->assertNotNull($location); + + $parsed = parse_url($location); + $actualQuery = []; + parse_str($parsed['query'] ?? '', $actualQuery); + + $this->assertSame('tenant_mismatch', $actualQuery['error'] ?? null); + $this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null); + } + + public function test_authorize_redirects_with_error_when_client_unknown(): void + { + $tenant = Tenant::factory()->create(); + $this->actingAs(User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + ])); + + $query = $this->buildAuthorizeQuery(new OAuthClient([ + 'client_id' => 'missing-client', + 'redirect_uris' => ['http://localhost/callback'], + 'scopes' => ['tenant:read', 'tenant:write'], + ])); + + $response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query)); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + $this->assertNotNull($location); + + $parsed = parse_url($location); + $actualQuery = []; + parse_str($parsed['query'] ?? '', $actualQuery); + + $this->assertSame('invalid_client', $actualQuery['error'] ?? null); + $this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null); + } + + public function test_authorize_returns_json_error_for_tenant_mismatch_when_requested(): void + { + $homeTenant = Tenant::factory()->create(); + $otherTenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'tenant_id' => $homeTenant->id, + 'role' => 'tenant_admin', + ]); + + $client = $this->createClientForTenant($otherTenant); + + $this->actingAs($user); + + $query = $this->buildAuthorizeQuery($client); + $response = $this->withHeaders(['Accept' => 'application/json']) + ->get('/api/v1/oauth/authorize?'.http_build_query($query)); + + $response->assertStatus(403) + ->assertJson([ + 'error' => 'tenant_mismatch', + ]); + } + + private function createClientForTenant(Tenant $tenant): OAuthClient + { + return OAuthClient::create([ + 'id' => (string) Str::uuid(), + 'client_id' => 'tenant-admin-app-'.$tenant->id, + 'tenant_id' => $tenant->id, + 'client_secret' => null, + 'redirect_uris' => ['http://localhost/callback'], + 'scopes' => ['tenant:read', 'tenant:write'], + 'is_active' => true, + ]); + } + + private function buildAuthorizeQuery(OAuthClient $client): array + { + return [ + 'client_id' => $client->client_id, + 'redirect_uri' => 'http://localhost/callback', + 'response_type' => 'code', + 'scope' => 'tenant:read tenant:write', + 'state' => Str::random(10), + 'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', Str::random(32), true)), '+/', '-_'), '='), + 'code_challenge_method' => 'S256', + ]; + } + + private function assertIntendedUrlMatches(array $expectedQuery): void + { + $intended = session('url.intended'); + $this->assertNotNull($intended, 'Expected intended URL to be recorded in session.'); + + $parts = parse_url($intended); + $this->assertSame('/api/v1/oauth/authorize', $parts['path'] ?? null); + + $actualQuery = []; + parse_str($parts['query'] ?? '', $actualQuery); + + $this->assertEqualsCanonicalizing($expectedQuery, $actualQuery); + } + + private function decodeReturnTo(?string $value): ?string + { + if ($value === null) { + return null; + } + + $padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '='); + $normalized = strtr($padded, '-_', '+/'); + + return base64_decode($normalized) ?: null; + } + + private function assertReturnToMatches(array $expectedQuery, ?string $encoded): void + { + $decoded = $this->decodeReturnTo($encoded); + $this->assertNotNull($decoded, 'Failed to decode return_to parameter.'); + + $parts = parse_url($decoded); + $this->assertSame('/api/v1/oauth/authorize', $parts['path'] ?? null); + + $actualQuery = []; + parse_str($parts['query'] ?? '', $actualQuery); + + $this->assertEqualsCanonicalizing($expectedQuery, $actualQuery); + } +} diff --git a/tests/Feature/Profile/ProfilePageTest.php b/tests/Feature/Profile/ProfilePageTest.php new file mode 100644 index 0000000..3a096cd --- /dev/null +++ b/tests/Feature/Profile/ProfilePageTest.php @@ -0,0 +1,76 @@ +create([ + 'event_credits_balance' => 7, + 'subscription_status' => 'active', + 'subscription_expires_at' => now()->addMonths(3), + ]); + + $package = Package::factory()->reseller()->create([ + 'name_translations' => [ + 'de' => 'Business Paket', + 'en' => 'Business Package', + ], + ]); + + TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'price' => 199.00, + 'purchased_at' => now()->subWeek(), + 'expires_at' => now()->addMonths(3), + 'used_events' => 1, + 'active' => true, + ]); + + PackagePurchase::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'price' => 199.00, + 'type' => 'reseller_subscription', + 'provider' => 'paddle', + 'purchased_at' => now()->subWeek(), + ]); + + $user = User::factory()->unverified()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + 'name' => 'Alex Beispiel', + 'email' => 'alex@example.test', + 'preferred_locale' => 'de', + ]); + + $this->actingAs($user); + + $response = $this->get(route('profile.index')); + + $response->assertStatus(200) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/Index') + ->where('userData.email', 'alex@example.test') + ->where('userData.mustVerifyEmail', true) + ->where('tenant.activePackage.name', 'Business Paket') + ->has('purchases', fn (AssertableInertia $purchases) => $purchases + ->where('0.packageName', 'Business Paket') + ->etc() + ) + ); + } +} diff --git a/tests/Feature/Tenant/EventCreditsTest.php b/tests/Feature/Tenant/EventCreditsTest.php index ad8572c..c8bd502 100644 --- a/tests/Feature/Tenant/EventCreditsTest.php +++ b/tests/Feature/Tenant/EventCreditsTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Tenant; use App\Models\EventType; +use App\Models\Package; use Illuminate\Support\Carbon; class EventCreditsTest extends TenantTestCase @@ -12,6 +13,20 @@ class EventCreditsTest extends TenantTestCase $this->tenant->update(['event_credits_balance' => 0]); $eventType = EventType::factory()->create(); + $package = Package::factory()->create([ + 'type' => 'endcustomer', + 'price' => 0, + 'gallery_days' => 30, + ]); + + $this->tenant->tenantPackages()->create([ + 'package_id' => $package->id, + 'price' => $package->price, + 'purchased_at' => now()->subDay(), + 'expires_at' => now()->addMonth(), + 'active' => true, + ]); + $payload = [ 'name' => 'Sample Event', 'description' => 'Test description', @@ -22,9 +37,8 @@ class EventCreditsTest extends TenantTestCase $response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload); $response->assertStatus(402) - ->assertJson([ - 'error' => 'Insufficient event credits. Please purchase more credits.', - ]); + ->assertJsonPath('error.code', 'event_credits_exhausted') + ->assertJsonPath('error.meta.balance', 0); $this->tenant->update(['event_credits_balance' => 2]); @@ -32,15 +46,14 @@ class EventCreditsTest extends TenantTestCase $createResponse->assertStatus(201) ->assertJsonPath('message', 'Event created successfully') - ->assertJsonPath('balance', 1); + ->assertJsonPath('data.package.id', $package->id); - $this->tenant->refresh(); - $this->assertSame(1, $this->tenant->event_credits_balance); + $createdEventId = $createResponse->json('data.id'); - $this->assertDatabaseHas('event_credits_ledger', [ - 'tenant_id' => $this->tenant->id, - 'delta' => -1, - 'reason' => 'event_create', + $this->assertNotNull($createdEventId); + $this->assertDatabaseHas('event_packages', [ + 'event_id' => $createdEventId, + 'package_id' => $package->id, ]); } } diff --git a/tests/Feature/Tenant/EventListTest.php b/tests/Feature/Tenant/EventListTest.php index 93d1c58..74bd9fd 100644 --- a/tests/Feature/Tenant/EventListTest.php +++ b/tests/Feature/Tenant/EventListTest.php @@ -133,6 +133,8 @@ class EventListTest extends TenantTestCase $matchingEvent = collect($response->json('data'))->firstWhere('id', $event->id); $this->assertNotNull($matchingEvent, 'Event should still be returned even if package record is missing.'); - $this->assertNull($matchingEvent['package'], 'Package payload should be null when relation cannot be resolved.'); + $this->assertIsArray($matchingEvent['package'], 'Package payload should provide fallback data when relation is missing.'); + $this->assertSame($package->id, $matchingEvent['package']['id']); + $this->assertSame((string) $package->price, $matchingEvent['package']['price']); } } diff --git a/tests/Feature/Tenant/ProfileApiTest.php b/tests/Feature/Tenant/ProfileApiTest.php new file mode 100644 index 0000000..00b57ec --- /dev/null +++ b/tests/Feature/Tenant/ProfileApiTest.php @@ -0,0 +1,89 @@ +authenticatedRequest('GET', '/api/v1/tenant/profile'); + + $response->assertOk(); + $payload = $response->json('data'); + + $this->assertSame($this->tenantUser->id, $payload['id']); + $this->assertSame($this->tenantUser->email, $payload['email']); + $this->assertSame($this->tenantUser->name, $payload['name']); + $this->assertTrue($payload['email_verified']); + } + + public function test_profile_update_allows_name_and_email_changes(): void + { + Notification::fake(); + + $newEmail = 'updated-'.$this->tenantUser->id.'@example.com'; + + $response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [ + 'name' => 'Updated Name', + 'email' => $newEmail, + 'preferred_locale' => 'en', + ]); + + $response->assertOk(); + + $payload = $response->json('data'); + $this->assertSame('Updated Name', $payload['name']); + $this->assertSame($newEmail, $payload['email']); + $this->assertFalse($payload['email_verified']); + $this->assertSame('en', $payload['preferred_locale']); + + $this->assertDatabaseHas(User::class, [ + 'id' => $this->tenantUser->id, + 'name' => 'Updated Name', + 'email' => $newEmail, + 'preferred_locale' => 'en', + ]); + + Notification::assertSentToTimes($this->tenantUser->fresh(), \Illuminate\Auth\Notifications\VerifyEmail::class, 1); + } + + public function test_profile_update_requires_current_password_for_password_change(): void + { + $response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [ + 'name' => $this->tenantUser->name, + 'email' => $this->tenantUser->email, + 'current_password' => 'wrong-password', + 'password' => 'new-secure-password', + 'password_confirmation' => 'new-secure-password', + ]); + + $response->assertStatus(422); + $response->assertJson([ + 'error' => [ + 'code' => 'profile.invalid_current_password', + ], + ]); + } + + public function test_profile_update_allows_password_change_with_correct_current_password(): void + { + $newPassword = 'NewStrongPassword123!'; + + $response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [ + 'name' => $this->tenantUser->name, + 'email' => $this->tenantUser->email, + 'current_password' => 'password', + 'password' => $newPassword, + 'password_confirmation' => $newPassword, + ]); + + $response->assertOk(); + + $this->tenantUser->refresh(); + $this->assertTrue(Hash::check($newPassword, $this->tenantUser->password)); + } +} diff --git a/tests/Feature/Tenant/SettingsApiTest.php b/tests/Feature/Tenant/SettingsApiTest.php index ea06ccb..a7df286 100644 --- a/tests/Feature/Tenant/SettingsApiTest.php +++ b/tests/Feature/Tenant/SettingsApiTest.php @@ -5,16 +5,16 @@ namespace Tests\Feature\Tenant; use App\Models\Tenant; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Laravel\Sanctum\Sanctum; use PHPUnit\Framework\Attributes\Test; -use Tests\TestCase; class SettingsApiTest extends TenantTestCase { use RefreshDatabase; protected Tenant $tenant; + protected User $tenantUser; + protected string $token; protected function setUp(): void @@ -37,9 +37,9 @@ class SettingsApiTest extends TenantTestCase $response = $this->authenticatedRequest('GET', '/api/v1/tenant/settings'); $response->assertStatus(200) - ->assertJson(['message' => 'Settings erfolgreich abgerufen.']) - ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') - ->assertJsonPath('data.settings.features.photo_likes_enabled', true); + ->assertJson(['message' => 'Settings erfolgreich abgerufen.']) + ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') + ->assertJsonPath('data.settings.features.photo_likes_enabled', true); } #[Test] @@ -64,10 +64,10 @@ class SettingsApiTest extends TenantTestCase $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $settingsData); $response->assertStatus(200) - ->assertJson(['message' => 'Settings erfolgreich aktualisiert.']) - ->assertJsonPath('data.settings.branding.primary_color', '#FF6B6B') - ->assertJsonPath('data.settings.features.photo_likes_enabled', false) - ->assertJsonPath('data.settings.custom_domain', 'custom.example.com'); + ->assertJson(['message' => 'Settings erfolgreich aktualisiert.']) + ->assertJsonPath('data.settings.branding.primary_color', '#FF6B6B') + ->assertJsonPath('data.settings.features.photo_likes_enabled', false) + ->assertJsonPath('data.settings.custom_domain', 'custom.example.com'); $this->assertDatabaseHas('tenants', [ 'id' => $this->tenant->id, @@ -89,9 +89,9 @@ class SettingsApiTest extends TenantTestCase $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $invalidData); $response->assertStatus(422) - ->assertJsonValidationErrors([ - 'settings.branding.primary_color', - ]); + ->assertJsonValidationErrors([ + 'settings.branding.primary_color', + ]); } #[Test] @@ -100,9 +100,9 @@ class SettingsApiTest extends TenantTestCase $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/reset'); $response->assertStatus(200) - ->assertJson(['message' => 'Settings auf Standardwerte zurueckgesetzt.']) - ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') - ->assertJsonPath('data.settings.features.photo_likes_enabled', true); + ->assertJson(['message' => 'Settings auf Standardwerte zurueckgesetzt.']) + ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') + ->assertJsonPath('data.settings.features.photo_likes_enabled', true); $this->assertDatabaseHas('tenants', [ 'id' => $this->tenant->id, @@ -135,8 +135,8 @@ class SettingsApiTest extends TenantTestCase ]); $response->assertStatus(200) - ->assertJson(['available' => true]) - ->assertJson(['message' => 'Domain ist verfuegbar.']); + ->assertJson(['available' => true]) + ->assertJson(['message' => 'Domain ist verfuegbar.']); // Invalid domain format $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [ @@ -144,19 +144,19 @@ class SettingsApiTest extends TenantTestCase ]); $response->assertStatus(200) - ->assertJson(['available' => false]) - ->assertJson(['message' => 'Ungueltiges Domain-Format.']); + ->assertJson(['available' => false]) + ->assertJson(['message' => 'Ungueltiges Domain-Format.']); // Taken domain (create another tenant with same domain) $otherTenant = Tenant::factory()->create(['custom_domain' => 'taken.example.com']); - + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [ 'domain' => 'taken.example.com', ]); $response->assertStatus(200) - ->assertJson(['available' => false]) - ->assertJson(['message' => 'Domain ist bereits vergeben.']); + ->assertJson(['available' => false]) + ->assertJson(['message' => 'Domain ist bereits vergeben.']); } #[Test] @@ -165,7 +165,8 @@ class SettingsApiTest extends TenantTestCase $response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain'); $response->assertStatus(400) - ->assertJson(['error' => 'Domain ist erforderlich.']); + ->assertJsonPath('error.code', 'domain_missing') + ->assertJsonPath('error.message', 'Bitte gib eine Domain an.'); } #[Test] @@ -178,15 +179,13 @@ class SettingsApiTest extends TenantTestCase 'tenant_id' => $otherTenant->id, 'role' => 'admin', ]); - $otherToken = 'mock-jwt-token-' . $otherTenant->id . '-' . time(); + $otherToken = 'mock-jwt-token-'.$otherTenant->id.'-'.time(); // This tenant's user should not see other tenant's settings $response = $this->authenticatedRequest('GET', '/api/v1/tenant/settings'); $response->assertStatus(200) - ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') // Default for this tenant - ->assertJsonMissing(['#FF0000']); // Other tenant's color + ->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') // Default for this tenant + ->assertJsonMissing(['#FF0000']); // Other tenant's color } } - - diff --git a/tests/Feature/Tenant/TaskApiTest.php b/tests/Feature/Tenant/TaskApiTest.php index 45e1882..fd732b6 100644 --- a/tests/Feature/Tenant/TaskApiTest.php +++ b/tests/Feature/Tenant/TaskApiTest.php @@ -8,16 +8,16 @@ use App\Models\TaskCollection; use App\Models\Tenant; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Laravel\Sanctum\Sanctum; use PHPUnit\Framework\Attributes\Test; -use Tests\TestCase; class TaskApiTest extends TenantTestCase { use RefreshDatabase; protected Tenant $tenant; + protected User $tenantUser; + protected string $token; protected function setUp(): void @@ -45,7 +45,7 @@ class TaskApiTest extends TenantTestCase $response = $this->authenticatedRequest('GET', '/api/v1/tenant/tasks'); $response->assertStatus(200) - ->assertJsonCount(3, 'data'); + ->assertJsonCount(3, 'data'); } #[Test] @@ -62,11 +62,11 @@ class TaskApiTest extends TenantTestCase 'priority' => 'medium', ]); - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->getJson('/api/v1/tenant/tasks'); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->getJson('/api/v1/tenant/tasks'); $response->assertStatus(200) - ->assertJsonCount(3, 'data'); + ->assertJsonCount(3, 'data'); } #[Test] @@ -79,28 +79,28 @@ class TaskApiTest extends TenantTestCase 'due_date' => now()->addDays(7)->toISOString(), ]; - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->postJson('/api/v1/tenant/tasks', $taskData); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->postJson('/api/v1/tenant/tasks', $taskData); $response->assertStatus(201) - ->assertJson(['message' => 'Task erfolgreich erstellt.']) - ->assertJsonPath('data.title', 'Test Task') - ->assertJsonPath('data.tenant_id', $this->tenant->id); + ->assertJson(['message' => 'Task erfolgreich erstellt.']) + ->assertJsonPath('data.title', 'Test Task') + ->assertJsonPath('data.tenant_id', $this->tenant->id); $this->assertDatabaseHas('tasks', [ - 'title' => 'Test Task', 'tenant_id' => $this->tenant->id, + 'title->de' => 'Test Task', ]); } #[Test] public function task_creation_requires_valid_data() { - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->postJson('/api/v1/tenant/tasks', []); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->postJson('/api/v1/tenant/tasks', []); $response->assertStatus(422) - ->assertJsonValidationErrors(['title']); + ->assertJsonValidationErrors(['title']); } #[Test] @@ -108,15 +108,18 @@ class TaskApiTest extends TenantTestCase { $task = Task::factory()->create([ 'tenant_id' => $this->tenant->id, - 'title' => 'Viewable Task', + 'title' => [ + 'de' => 'Viewable Task', + 'en' => 'Viewable Task', + ], 'priority' => 'medium', ]); - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->getJson("/api/v1/tenant/tasks/{$task->id}"); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->getJson("/api/v1/tenant/tasks/{$task->id}"); $response->assertStatus(200) - ->assertJson(['title' => 'Viewable Task']); + ->assertJson(['title' => 'Viewable Task']); } #[Test] @@ -129,8 +132,8 @@ class TaskApiTest extends TenantTestCase 'priority' => 'medium', ]); - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->getJson("/api/v1/tenant/tasks/{$otherTask->id}"); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->getJson("/api/v1/tenant/tasks/{$otherTask->id}"); $response->assertStatus(404); } @@ -140,7 +143,10 @@ class TaskApiTest extends TenantTestCase { $task = Task::factory()->create([ 'tenant_id' => $this->tenant->id, - 'title' => 'Old Title', + 'title' => [ + 'de' => 'Old Title', + 'en' => 'Old Title', + ], 'priority' => 'low', ]); @@ -149,17 +155,17 @@ class TaskApiTest extends TenantTestCase 'priority' => 'urgent', ]; - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->patchJson("/api/v1/tenant/tasks/{$task->id}", $updateData); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->patchJson("/api/v1/tenant/tasks/{$task->id}", $updateData); $response->assertStatus(200) - ->assertJson(['message' => 'Task erfolgreich aktualisiert.']) - ->assertJsonPath('data.title', 'Updated Title') - ->assertJsonPath('data.priority', 'urgent'); + ->assertJson(['message' => 'Task erfolgreich aktualisiert.']) + ->assertJsonPath('data.title', 'Updated Title') + ->assertJsonPath('data.priority', 'urgent'); $this->assertDatabaseHas('tasks', [ 'id' => $task->id, - 'title' => 'Updated Title', + 'title->de' => 'Updated Title', 'priority' => 'urgent', ]); } @@ -172,11 +178,11 @@ class TaskApiTest extends TenantTestCase 'priority' => 'medium', ]); - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->deleteJson("/api/v1/tenant/tasks/{$task->id}"); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->deleteJson("/api/v1/tenant/tasks/{$task->id}"); $response->assertStatus(200) - ->assertJson(['message' => 'Task erfolgreich gelöscht.']); + ->assertJson(['message' => 'Task erfolgreich gelöscht.']); $this->assertSoftDeleted('tasks', ['id' => $task->id]); } @@ -190,14 +196,13 @@ class TaskApiTest extends TenantTestCase ]); $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, - 'event_type_id' => 1, ]); - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->postJson("/api/v1/tenant/tasks/{$task->id}/assign-event/{$event->id}"); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->postJson("/api/v1/tenant/tasks/{$task->id}/assign-event/{$event->id}"); $response->assertStatus(200) - ->assertJson(['message' => 'Task erfolgreich dem Event zugewiesen.']); + ->assertJson(['message' => 'Task erfolgreich dem Event zugewiesen.']); $this->assertDatabaseHas('event_task', [ 'task_id' => $task->id, @@ -214,16 +219,15 @@ class TaskApiTest extends TenantTestCase ]); $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, - 'event_type_id' => 1, ]); - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->postJson("/api/v1/tenant/tasks/bulk-assign-event/{$event->id}", [ - 'task_ids' => $tasks->pluck('id')->toArray(), - ]); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->postJson("/api/v1/tenant/tasks/bulk-assign-event/{$event->id}", [ + 'task_ids' => $tasks->pluck('id')->toArray(), + ]); $response->assertStatus(200) - ->assertJson(['message' => '3 Tasks dem Event zugewiesen.']); + ->assertJson(['message' => '3 Tasks dem Event zugewiesen.']); $this->assertEquals(3, $event->tasks()->count()); } @@ -233,24 +237,23 @@ class TaskApiTest extends TenantTestCase { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, - 'event_type_id' => 1, ]); $eventTasks = Task::factory(2)->create([ 'tenant_id' => $this->tenant->id, 'priority' => 'medium', ]); - $eventTasks->each(fn($task) => $task->assignedEvents()->attach($event->id)); + $eventTasks->each(fn ($task) => $task->assignedEvents()->attach($event->id)); Task::factory(3)->create([ 'tenant_id' => $this->tenant->id, 'priority' => 'medium', ]); // Other tasks - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->getJson("/api/v1/tenant/tasks/event/{$event->id}"); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->getJson("/api/v1/tenant/tasks/event/{$event->id}"); $response->assertStatus(200) - ->assertJsonCount(2, 'data'); + ->assertJsonCount(2, 'data'); } #[Test] @@ -272,11 +275,11 @@ class TaskApiTest extends TenantTestCase 'priority' => 'medium', ]); // Other tasks - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->getJson("/api/v1/tenant/tasks?collection_id={$collection->id}"); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->getJson("/api/v1/tenant/tasks?collection_id={$collection->id}"); $response->assertStatus(200) - ->assertJsonCount(2, 'data'); + ->assertJsonCount(2, 'data'); } #[Test] @@ -284,29 +287,25 @@ class TaskApiTest extends TenantTestCase { Task::factory()->create([ 'tenant_id' => $this->tenant->id, - 'title' => 'First Task', - 'priority' => 'medium' + 'title' => ['de' => 'First Task', 'en' => 'First Task'], + 'priority' => 'medium', ]); Task::factory()->create([ 'tenant_id' => $this->tenant->id, - 'title' => 'Search Test', - 'priority' => 'medium' + 'title' => ['de' => 'Search Test', 'en' => 'Search Test'], + 'priority' => 'medium', ]); Task::factory()->create([ 'tenant_id' => $this->tenant->id, - 'title' => 'Another Task', - 'priority' => 'medium' + 'title' => ['de' => 'Another Task', 'en' => 'Another Task'], + 'priority' => 'medium', ]); - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]) - ->getJson('/api/v1/tenant/tasks?search=Search'); + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->getJson('/api/v1/tenant/tasks?search=Search'); $response->assertStatus(200) - ->assertJsonCount(1, 'data') - ->assertJsonPath('data.0.title', 'Search Test'); + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.title', 'Search Test'); } } - - - - diff --git a/tests/Feature/Tenant/TenantTestCase.php b/tests/Feature/Tenant/TenantTestCase.php index 7884b88..4b5cb2d 100644 --- a/tests/Feature/Tenant/TenantTestCase.php +++ b/tests/Feature/Tenant/TenantTestCase.php @@ -14,9 +14,13 @@ abstract class TenantTestCase extends TestCase use RefreshDatabase; protected Tenant $tenant; + protected User $tenantUser; + protected OAuthClient $oauthClient; + protected string $token; + protected ?string $refreshToken = null; protected function setUp(): void @@ -75,6 +79,8 @@ abstract class TenantTestCase extends TestCase protected function issueTokens(OAuthClient $client, array $scopes = ['tenant:read', 'tenant:write']): array { + $this->actingAs($this->tenantUser); + $codeVerifier = 'tenant-code-verifier-'.Str::random(32); $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); $state = Str::random(10); @@ -114,4 +120,3 @@ abstract class TenantTestCase extends TestCase ]; } } -