diff --git a/.gitignore b/.gitignore index d8e47a8..945e31c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ yarn-error.log tools/git-askpass.ps1 docker podman-compose.dev.yml +test-results diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 430317b..7e308a7 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -279,7 +279,6 @@ class EventPublicController extends BaseController 'photos.likes_count', 'photos.emotion_id', 'photos.task_id', - 'photos.created_at', 'photos.guest_name', 'tasks.title as task_title' ]) @@ -503,4 +502,220 @@ class EventPublicController extends BaseController return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists) } + public function achievements(Request $request, string $slug) + { + $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']); + if (! $event) { + return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); + } + + $eventId = $event->id; + $locale = $request->query('locale', 'de'); + + $totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count(); + $uniqueGuests = (int) DB::table('photos')->where('event_id', $eventId)->distinct('guest_name')->count('guest_name'); + $tasksSolved = (int) DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count(); + $likesTotal = (int) DB::table('photos')->where('event_id', $eventId)->sum('likes_count'); + + $summary = [ + 'total_photos' => $totalPhotos, + 'unique_guests' => $uniqueGuests, + 'tasks_solved' => $tasksSolved, + 'likes_total' => $likesTotal, + ]; + + $guestNameParam = trim((string) $request->query('guest_name', '')); + $deviceIdHeader = (string) $request->headers->get('X-Device-Id', ''); + $deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120); + $candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId; + $guestIdentifier = $candidate !== '' ? substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120) : null; + + $personal = null; + if ($guestIdentifier) { + $personalPhotos = (int) DB::table('photos') + ->where('event_id', $eventId) + ->where('guest_name', $guestIdentifier) + ->count(); + + $personalTasks = (int) DB::table('photos') + ->where('event_id', $eventId) + ->where('guest_name', $guestIdentifier) + ->whereNotNull('task_id') + ->distinct('task_id') + ->count('task_id'); + + $personalLikes = (int) DB::table('photos') + ->where('event_id', $eventId) + ->where('guest_name', $guestIdentifier) + ->sum('likes_count'); + + $badgeBlueprints = [ + ['id' => 'first_photo', 'title' => 'Erstes Foto', 'description' => 'Lade dein erstes Foto hoch.', 'target' => 1, 'metric' => 'photos'], + ['id' => 'five_photos', 'title' => 'Fotoflair', 'description' => 'Fuenf Fotos hochgeladen.', 'target' => 5, 'metric' => 'photos'], + ['id' => 'first_task', 'title' => 'Aufgabenstarter', 'description' => 'Erste Aufgabe erfuellt.', 'target' => 1, 'metric' => 'tasks'], + ['id' => 'task_master', 'title' => 'Aufgabenmeister', 'description' => 'Fuenf Aufgaben erfuellt.', 'target' => 5, 'metric' => 'tasks'], + ['id' => 'crowd_pleaser', 'title' => 'Publikumsliebling', 'description' => 'Sammle zwanzig Likes.', 'target' => 20, 'metric' => 'likes'], + ]; + + $personalBadges = []; + foreach ($badgeBlueprints as $badge) { + $value = 0; + if ($badge['metric'] === 'photos') { + $value = $personalPhotos; + } elseif ($badge['metric'] === 'tasks') { + $value = $personalTasks; + } else { + $value = $personalLikes; + } + + $personalBadges[] = [ + 'id' => $badge['id'], + 'title' => $badge['title'], + 'description' => $badge['description'], + 'earned' => $value >= $badge['target'], + 'progress' => $value, + 'target' => $badge['target'], + ]; + } + + $personal = [ + 'guest_name' => $guestIdentifier, + 'photos' => $personalPhotos, + 'tasks' => $personalTasks, + 'likes' => $personalLikes, + 'badges' => $personalBadges, + ]; + } + + $topUploads = DB::table('photos') + ->select('guest_name', DB::raw('COUNT(*) as total'), DB::raw('SUM(likes_count) as likes')) + ->where('event_id', $eventId) + ->groupBy('guest_name') + ->orderByDesc('total') + ->limit(5) + ->get() + ->map(fn ($row) => [ + 'guest' => $row->guest_name, + 'photos' => (int) $row->total, + 'likes' => (int) $row->likes, + ]) + ->values(); + + $topLikes = DB::table('photos') + ->select('guest_name', DB::raw('SUM(likes_count) as likes'), DB::raw('COUNT(*) as total')) + ->where('event_id', $eventId) + ->groupBy('guest_name') + ->orderByDesc('likes') + ->limit(5) + ->get() + ->map(fn ($row) => [ + 'guest' => $row->guest_name, + 'likes' => (int) $row->likes, + 'photos' => (int) $row->total, + ]) + ->values(); + + $topPhotoRow = DB::table('photos') + ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') + ->where('photos.event_id', $eventId) + ->orderByDesc('photos.likes_count') + ->orderByDesc('photos.created_at') + ->select([ + 'photos.id', + 'photos.guest_name', + 'photos.likes_count', + 'photos.file_path', + 'photos.thumbnail_path', + 'photos.created_at', + 'tasks.title as task_title', + ]) + ->first(); + + $topPhoto = $topPhotoRow ? [ + 'photo_id' => (int) $topPhotoRow->id, + 'guest' => $topPhotoRow->guest_name, + 'likes' => (int) $topPhotoRow->likes_count, + 'task' => $topPhotoRow->task_title, + 'created_at' => $topPhotoRow->created_at, + 'thumbnail' => $this->toPublicUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path), + ] : null; + + $trendingEmotionRow = DB::table('photos') + ->join('emotions', 'photos.emotion_id', '=', 'emotions.id') + ->where('photos.event_id', $eventId) + ->select('emotions.id', 'emotions.name', DB::raw('COUNT(*) as total')) + ->groupBy('emotions.id', 'emotions.name') + ->orderByDesc('total') + ->first(); + + $trendingEmotion = $trendingEmotionRow ? [ + 'emotion_id' => (int) $trendingEmotionRow->id, + 'name' => $this->getLocalized($trendingEmotionRow->name, $locale, $trendingEmotionRow->name), + 'count' => (int) $trendingEmotionRow->total, + ] : null; + + $timeline = DB::table('photos') + ->where('event_id', $eventId) + ->selectRaw('DATE(created_at) as day, COUNT(*) as photos, COUNT(DISTINCT guest_name) as guests') + ->groupBy('day') + ->orderBy('day') + ->limit(14) + ->get() + ->map(fn ($row) => [ + 'date' => $row->day, + 'photos' => (int) $row->photos, + 'guests' => (int) $row->guests, + ]) + ->values(); + + $feed = DB::table('photos') + ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') + ->where('photos.event_id', $eventId) + ->orderByDesc('photos.created_at') + ->limit(12) + ->get([ + 'photos.id', + 'photos.guest_name', + 'photos.thumbnail_path', + 'photos.file_path', + 'photos.likes_count', + 'photos.created_at', + 'tasks.title as task_title', + ]) + ->map(fn ($row) => [ + 'photo_id' => (int) $row->id, + 'guest' => $row->guest_name, + 'task' => $row->task_title, + 'likes' => (int) $row->likes_count, + 'created_at' => $row->created_at, + 'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path), + ]) + ->values(); + + $payload = [ + 'summary' => $summary, + 'personal' => $personal, + 'leaderboards' => [ + 'uploads' => $topUploads, + 'likes' => $topLikes, + ], + 'highlights' => [ + 'top_photo' => $topPhoto, + 'trending_emotion' => $trendingEmotion, + 'timeline' => $timeline, + ], + 'feed' => $feed, + ]; + + $etag = sha1(json_encode($payload)); + $ifNoneMatch = $request->headers->get('If-None-Match'); + if ($ifNoneMatch && $ifNoneMatch === $etag) { + return response('', 304); + } + + return response()->json($payload) + ->header('Cache-Control', 'no-store') + ->header('ETag', $etag); + } } + diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 201166f..651e742 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -108,7 +108,7 @@ class PhotoController extends Controller 'width' => null, // To be filled by image processing 'height' => null, 'status' => 'pending', // Requires moderation - 'uploader_id' => $request->user()->id ?? null, + 'uploader_id' => null, 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), ]); @@ -170,8 +170,8 @@ class PhotoController extends Controller ]); // Only tenant admins can moderate - if (isset($validated['status']) && $request->user()->role !== 'admin') { - return response()->json(['error' => 'Insufficient permissions for moderation'], 403); + if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { + return response()->json(['error' => 'Insufficient scopes'], 403); } $photo->update($validated); @@ -243,7 +243,7 @@ class PhotoController extends Controller 'status' => 'approved', 'moderation_notes' => $request->moderation_notes, 'moderated_at' => now(), - 'moderated_by' => $request->user()->id, + 'moderated_by' => null, ]); // Load approved photos for response @@ -288,7 +288,7 @@ class PhotoController extends Controller 'status' => 'rejected', 'moderation_notes' => $request->moderation_notes, 'moderated_at' => now(), - 'moderated_by' => $request->user()->id, + 'moderated_by' => null, ]); // Optionally delete rejected photos from storage @@ -369,6 +369,17 @@ class PhotoController extends Controller ]); } + private function tokenHasScope(Request $request, string $scope): bool + { + $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []); + + if (! is_array($scopes)) { + $scopes = array_values(array_filter(explode(' ', (string) $scopes))); + } + + return in_array($scope, $scopes, true); + } + /** * Generate presigned S3 URL for direct upload (alternative to local storage) */ @@ -467,4 +478,10 @@ class PhotoController extends Controller 'status' => 'pending', ], 201); } -} \ No newline at end of file +} + + + + + + diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php index 0150d91..a2d6308 100644 --- a/app/Http/Controllers/OAuthController.php +++ b/app/Http/Controllers/OAuthController.php @@ -8,18 +8,22 @@ use App\Models\RefreshToken; use App\Models\Tenant; use App\Models\TenantToken; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; -use Illuminate\Validation\ValidationException; use Firebase\JWT\JWT; -use Firebase\JWT\Key; use GuzzleHttp\Client; use Illuminate\Support\Facades\Log; class OAuthController extends Controller { + private const AUTH_CODE_TTL_MINUTES = 5; + private const ACCESS_TOKEN_TTL_SECONDS = 3600; + private const REFRESH_TOKEN_TTL_DAYS = 30; + private const TOKEN_HEADER_KID = 'fotospiel-jwt'; + /** * Authorize endpoint - PKCE flow */ @@ -28,6 +32,7 @@ class OAuthController extends Controller $validator = Validator::make($request->all(), [ 'client_id' => 'required|string', 'redirect_uri' => 'required|url', + 'response_type' => 'required|in:code', 'scope' => 'required|string', 'state' => 'nullable|string', 'code_challenge' => 'required|string', @@ -38,244 +43,425 @@ class OAuthController extends Controller return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); } - $client = OAuthClient::where('client_id', $request->client_id) + /** @var OAuthClient|null $client */ + $client = OAuthClient::query() + ->where('client_id', $request->string('client_id')) ->where('is_active', true) ->first(); - if (!$client) { + if (! $client) { return $this->errorResponse('Invalid client', 401); } - // Validate redirect URI - $redirectUris = is_array($client->redirect_uris) ? $client->redirect_uris : json_decode($client->redirect_uris, true); - if (!in_array($request->redirect_uri, $redirectUris)) { + $allowedRedirects = (array) $client->redirect_uris; + if (! in_array($request->redirect_uri, $allowedRedirects, true)) { return $this->errorResponse('Invalid redirect URI', 400); } - // Validate scopes - $requestedScopes = explode(' ', $request->scope); - $availableScopes = is_array($client->scopes) ? array_keys($client->scopes) : []; - $validScopes = array_intersect($requestedScopes, $availableScopes); - - if (count($validScopes) !== count($requestedScopes)) { + $requestedScopes = $this->parseScopes($request->string('scope')); + $availableScopes = (array) $client->scopes; + if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) { return $this->errorResponse('Invalid scopes requested', 400); } - // Generate authorization code (PKCE validated later) - $code = Str::random(40); - $codeId = Str::uuid(); + $tenantId = $client->tenant_id ?? Tenant::query()->orderBy('id')->value('id'); + if (! $tenantId) { + return $this->errorResponse('Unable to resolve tenant for client', 500); + } - // Store code in Redis with 5min TTL - Cache::put("oauth_code:{$codeId}", [ - 'code' => $code, - 'client_id' => $request->client_id, + $code = Str::random(64); + $codeId = (string) Str::uuid(); + $expiresAt = now()->addMinutes(self::AUTH_CODE_TTL_MINUTES); + $cacheKey = $this->cacheKeyForCode($code); + + Cache::put($cacheKey, [ + 'id' => $codeId, + 'client_id' => $client->client_id, + 'tenant_id' => $tenantId, 'redirect_uri' => $request->redirect_uri, - 'scopes' => $validScopes, - 'state' => $request->state ?? null, + 'scopes' => $requestedScopes, + 'state' => $request->state, 'code_challenge' => $request->code_challenge, 'code_challenge_method' => $request->code_challenge_method, - 'expires_at' => now()->addMinutes(5), - ], now()->addMinutes(5)); + 'expires_at' => $expiresAt, + ], $expiresAt); - // Save to DB for persistence OAuthCode::create([ 'id' => $codeId, + 'client_id' => $client->client_id, + 'user_id' => (string) $tenantId, 'code' => Hash::make($code), - 'client_id' => $request->client_id, - 'scopes' => json_encode($validScopes), - 'state' => $request->state ?? null, - 'expires_at' => now()->addMinutes(5), + 'code_challenge' => $request->code_challenge, + 'state' => $request->state, + 'redirect_uri' => $request->redirect_uri, + 'scope' => implode(' ', $requestedScopes), + 'expires_at' => $expiresAt, ]); - $redirectUrl = $request->redirect_uri . '?' . http_build_query([ + $redirectUrl = $request->redirect_uri.'?'.http_build_query([ 'code' => $code, - 'state' => $request->state ?? '', + 'state' => $request->state, ]); - return redirect($redirectUrl); + return redirect()->away($redirectUrl); } /** - * Token endpoint - Code exchange for access/refresh tokens + * Token endpoint - Code exchange & refresh */ public function token(Request $request) + { + $grantType = (string) $request->string('grant_type'); + + if ($grantType === 'authorization_code') { + return $this->handleAuthorizationCodeGrant($request); + } + + if ($grantType === 'refresh_token') { + return $this->handleRefreshTokenGrant($request); + } + + return $this->errorResponse('Unsupported grant type', 400); + } + + /** + * Get tenant info based on decoded token + */ + public function me(Request $request) + { + $decoded = $request->attributes->get('decoded_token'); + $tenantId = Arr::get($decoded, 'tenant_id'); + + if (! $tenantId) { + return $this->errorResponse('Unauthenticated', 401); + } + + $tenant = Tenant::query()->find($tenantId); + if (! $tenant) { + Log::error('[OAuth] Tenant not found during token issuance', [ + 'client_id' => $request->client_id, + 'code_id' => $cachedCode['id'] ?? null, + 'tenant_id' => $tenantId, + ]); + + return $this->errorResponse('Tenant not found', 404); + } + + return response()->json([ + 'id' => $tenant->id, + 'tenant_id' => $tenant->id, + 'name' => $tenant->name, + 'slug' => $tenant->slug, + 'email' => $tenant->contact_email, + 'event_credits_balance' => $tenant->event_credits_balance, + 'subscription_tier' => $tenant->subscription_tier, + 'subscription_expires_at' => $tenant->subscription_expires_at, + 'features' => $tenant->features, + 'scopes' => Arr::get($decoded, 'scopes', []), + ]); + } + + private function handleAuthorizationCodeGrant(Request $request) { $validator = Validator::make($request->all(), [ 'grant_type' => 'required|in:authorization_code', 'code' => 'required|string', 'client_id' => 'required|string', 'redirect_uri' => 'required|url', - 'code_verifier' => 'required|string', // PKCE + 'code_verifier' => 'required|string', ]); if ($validator->fails()) { return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); } - // Find the code in cache/DB - $cachedCode = Cache::get($request->code); - if (!$cachedCode) { + $cacheKey = $this->cacheKeyForCode($request->code); + $cachedCode = Cache::get($cacheKey); + if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) { + Log::warning('[OAuth] Authorization code missing or expired', [ + 'client_id' => $request->client_id, + 'code_id' => $cachedCode['id'] ?? null, + ]); + + return $this->errorResponse('Invalid or expired authorization code', 400); + } + + /** @var OAuthCode|null $oauthCode */ + $oauthCode = OAuthCode::query()->find($cachedCode['id']); + if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) { + Log::warning('[OAuth] Authorization code validation failed', [ + 'client_id' => $request->client_id, + 'code_id' => $cachedCode['id'] ?? null, + ]); + return $this->errorResponse('Invalid authorization code', 400); } - $codeId = array_search($request->code, Cache::get($request->code)['code'] ?? []); - if (!$codeId) { - $oauthCode = OAuthCode::where('code', 'LIKE', '%' . $request->code . '%')->first(); - if (!$oauthCode || !Hash::check($request->code, $oauthCode->code)) { - return $this->errorResponse('Invalid authorization code', 400); - } - $cachedCode = $oauthCode->toArray(); - $codeId = $oauthCode->id; - } - - // Validate client - $client = OAuthClient::where('client_id', $request->client_id)->first(); - if (!$client || !$client->is_active) { + /** @var OAuthClient|null $client */ + $client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first(); + if (! $client) { return $this->errorResponse('Invalid client', 401); } - // PKCE validation - if ($cachedCode['code_challenge_method'] === 'S256') { - $expectedChallenge = $this->base64url_encode(hash('sha256', $request->code_verifier, true)); - if (!hash_equals($expectedChallenge, $cachedCode['code_challenge'])) { - return $this->errorResponse('Invalid code verifier', 400); - } - } else { - if (!hash_equals($request->code_verifier, $cachedCode['code'])) { - return $this->errorResponse('Invalid code verifier', 400); - } - } - - // Validate redirect URI - if ($request->redirect_uri !== $cachedCode['redirect_uri']) { + if ($request->redirect_uri !== Arr::get($cachedCode, 'redirect_uri')) { return $this->errorResponse('Invalid redirect URI', 400); } - // Code used - delete/invalidate - Cache::forget("oauth_code:{$codeId}"); - OAuthCode::where('id', $codeId)->delete(); + $codeChallengeMethod = Arr::get($cachedCode, 'code_challenge_method', 'S256'); + $expectedChallenge = $codeChallengeMethod === 'S256' + ? $this->base64urlEncode(hash('sha256', $request->code_verifier, true)) + : $request->code_verifier; + + if (! hash_equals($expectedChallenge, Arr::get($cachedCode, 'code_challenge'))) { + return $this->errorResponse('Invalid code verifier', 400); + } + + $tenantId = Arr::get($cachedCode, 'tenant_id') ?? $client->tenant_id; + $tenant = $tenantId ? Tenant::query()->find($tenantId) : null; + if (! $tenant) { + Log::error('[OAuth] Tenant not found during token issuance', [ + 'client_id' => $request->client_id, + 'code_id' => $cachedCode['id'] ?? null, + 'tenant_id' => $tenantId, + ]); - // Create tenant token (assuming client is tied to tenant - adjust as needed) - $tenant = Tenant::where('id', $client->tenant_id ?? 1)->first(); // Default tenant or from client - if (!$tenant) { return $this->errorResponse('Tenant not found', 404); } - // Generate JWT Access Token (RS256) - $privateKey = file_get_contents(storage_path('app/private.key')); // Ensure keys exist - if (!$privateKey) { - $this->generateKeyPair(); - $privateKey = file_get_contents(storage_path('app/private.key')); + $scopes = Arr::get($cachedCode, 'scopes', []); + if (empty($scopes)) { + $scopes = $this->parseScopes($oauthCode->scope); } - $accessToken = $this->generateJWT($tenant->id, $cachedCode['scopes'], 'access', 3600); // 1h - $refreshTokenId = Str::uuid(); - $refreshToken = Str::random(60); + Cache::forget($cacheKey); + $oauthCode->delete(); - // Store refresh token - RefreshToken::create([ - 'id' => $refreshTokenId, - 'token' => Hash::make($refreshToken), - 'tenant_id' => $tenant->id, - 'client_id' => $request->client_id, - 'scopes' => json_encode($cachedCode['scopes']), - 'expires_at' => now()->addDays(30), - 'ip_address' => $request->ip(), + $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request); + + return response()->json($tokenResponse); + } + + private function handleRefreshTokenGrant(Request $request) + { + $validator = Validator::make($request->all(), [ + 'grant_type' => 'required|in:refresh_token', + 'refresh_token' => 'required|string', + 'client_id' => 'required|string', ]); - return response()->json([ + if ($validator->fails()) { + return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); + } + + $tokenParts = explode('|', $request->refresh_token, 2); + if (count($tokenParts) !== 2) { + return $this->errorResponse('Malformed refresh token', 400); + } + + [$refreshTokenId, $refreshTokenSecret] = $tokenParts; + + /** @var RefreshToken|null $storedRefreshToken */ + $storedRefreshToken = RefreshToken::query() + ->where('id', $refreshTokenId) + ->whereNull('revoked_at') + ->first(); + + if (! $storedRefreshToken) { + return $this->errorResponse('Invalid refresh token', 400); + } + + if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) { + return $this->errorResponse('Refresh token does not match client', 400); + } + + if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) { + $storedRefreshToken->update(['revoked_at' => now()]); + return $this->errorResponse('Refresh token expired', 400); + } + + if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) { + return $this->errorResponse('Invalid refresh token', 400); + } + + $client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first(); + if (! $client) { + return $this->errorResponse('Invalid client', 401); + } + + $tenant = Tenant::query()->find($storedRefreshToken->tenant_id); + if (! $tenant) { + Log::error('[OAuth] Tenant not found during token issuance', [ + 'client_id' => $request->client_id, + 'code_id' => $cachedCode['id'] ?? null, + 'tenant_id' => $storedRefreshToken->tenant_id, + ]); + + return $this->errorResponse('Tenant not found', 404); + } + + $scopes = $this->parseScopes($storedRefreshToken->scope); + + $storedRefreshToken->update(['revoked_at' => now()]); + + $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request); + + return response()->json($tokenResponse); + } + + private function issueTokenPair(Tenant $tenant, OAuthClient $client, array $scopes, Request $request): array + { + $scopes = array_values(array_unique($scopes)); + $expiresIn = self::ACCESS_TOKEN_TTL_SECONDS; + $issuedAt = now(); + $jti = (string) Str::uuid(); + $expiresAt = $issuedAt->copy()->addSeconds($expiresIn); + + $accessToken = $this->generateJWT( + $tenant->id, + $client->client_id, + $scopes, + 'access', + $expiresIn, + $jti, + $issuedAt->timestamp, + $expiresAt->timestamp + ); + + TenantToken::create([ + 'id' => (string) Str::uuid(), + 'tenant_id' => $tenant->id, + 'jti' => $jti, + 'token_type' => 'access', + 'expires_at' => $expiresAt, + ]); + + $refreshToken = $this->createRefreshToken($tenant, $client, $scopes, $jti, $request); + + return [ 'token_type' => 'Bearer', 'access_token' => $accessToken, 'refresh_token' => $refreshToken, - 'expires_in' => 3600, - 'scope' => implode(' ', $cachedCode['scopes']), - ]); + 'expires_in' => $expiresIn, + 'scope' => implode(' ', $scopes), + ]; } - /** - * Get tenant info - */ - public function me(Request $request) + private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string { - $user = $request->user(); - if (!$user) { - return $this->errorResponse('Unauthenticated', 401); - } + $refreshTokenId = (string) Str::uuid(); + $secret = Str::random(64); + $composite = $refreshTokenId.'|'.$secret; + $expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS); - $tenant = $user->tenant; - if (!$tenant) { - return $this->errorResponse('Tenant not found', 404); - } - - return response()->json([ + RefreshToken::create([ + 'id' => $refreshTokenId, 'tenant_id' => $tenant->id, - 'name' => $tenant->name, - 'slug' => $tenant->slug, - 'event_credits_balance' => $tenant->event_credits_balance, - 'subscription_tier' => $tenant->subscription_tier, - 'subscription_expires_at' => $tenant->subscription_expires_at, - 'features' => $tenant->features, - 'scopes' => $request->user()->token()->abilities, + 'client_id' => $client->client_id, + 'token' => Hash::make($secret), + 'access_token' => $accessTokenJti, + 'expires_at' => $expiresAt, + 'scope' => implode(' ', $scopes), + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), ]); + + return $composite; } - /** - * Generate JWT token - */ - private function generateJWT($tenantId, $scopes, $type, $expiresIn) - { + private function generateJWT( + int $tenantId, + string $clientId, + array $scopes, + string $type, + int $expiresIn, + string $jti, + int $issuedAt, + int $expiresAt + ): string { + [$publicKey, $privateKey] = $this->ensureKeysExist(); + $payload = [ 'iss' => url('/'), - 'aud' => 'fotospiel-api', - 'iat' => time(), - 'nbf' => time(), - 'exp' => time() + $expiresIn, + 'aud' => $clientId, + 'iat' => $issuedAt, + 'nbf' => $issuedAt, + 'exp' => $expiresAt, 'sub' => $tenantId, + 'tenant_id' => $tenantId, 'scopes' => $scopes, 'type' => $type, + 'client_id' => $clientId, + 'jti' => $jti, ]; - $publicKey = file_get_contents(storage_path('app/public.key')); - $privateKey = file_get_contents(storage_path('app/private.key')); - - if (!$publicKey || !$privateKey) { - $this->generateKeyPair(); - $publicKey = file_get_contents(storage_path('app/public.key')); - $privateKey = file_get_contents(storage_path('app/private.key')); - } - - return JWT::encode($payload, $privateKey, 'RS256', null, null, null, ['kid' => 'fotospiel-jwt']); + return JWT::encode($payload, $privateKey, 'RS256', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]); } - /** - * Generate RSA key pair for JWT - */ - private function generateKeyPair() + private function ensureKeysExist(): array + { + $publicKeyPath = storage_path('app/public.key'); + $privateKeyPath = storage_path('app/private.key'); + + $publicKey = @file_get_contents($publicKeyPath); + $privateKey = @file_get_contents($privateKeyPath); + + if ($publicKey && $privateKey) { + return [$publicKey, $privateKey]; + } + + $this->generateKeyPair(); + + return [ + file_get_contents($publicKeyPath), + file_get_contents($privateKeyPath), + ]; + } + + private function generateKeyPair(): void { $config = [ - "digest_alg" => OPENSSL_ALGO_SHA256, - "private_key_bits" => 2048, - "private_key_type" => OPENSSL_KEYTYPE_RSA, + 'digest_alg' => OPENSSL_ALGO_SHA256, + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]; $res = openssl_pkey_new($config); - if (!$res) { - throw new \Exception('Failed to generate key pair'); + if (! $res) { + throw new \RuntimeException('Failed to generate key pair'); } - // Get private key - openssl_pkey_export($res, $privateKey); - file_put_contents(storage_path('app/private.key'), $privateKey); - - // Get public key + openssl_pkey_export($res, $privKey); $pubKey = openssl_pkey_get_details($res); - file_put_contents(storage_path('app/public.key'), $pubKey["key"]); + + file_put_contents(storage_path('app/private.key'), $privKey); + file_put_contents(storage_path('app/public.key'), $pubKey['key']); + } + private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool + { + if (empty($requestedScopes)) { + return false; + } + + $available = array_flip($availableScopes); + foreach ($requestedScopes as $scope) { + if (! isset($available[$scope])) { + return false; + } + } + + return true; } - /** - * Error response helper - */ - private function errorResponse($message, $status = 400, $errors = null) + private function parseScopes(?string $scopeString): array + { + if (! $scopeString) { + return []; + } + + return array_values(array_filter(explode(' ', trim($scopeString)))); + } + + private function errorResponse(string $message, int $status = 400, $errors = null) { $response = ['error' => $message]; if ($errors) { @@ -285,21 +471,23 @@ class OAuthController extends Controller return response()->json($response, $status); } - /** - * Helper function for base64url encoding - */ - private function base64url_encode($data) + private function base64urlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } + private function cacheKeyForCode(string $code): string + { + return 'oauth:code:'.hash('sha256', $code); + } + /** * Stripe Connect OAuth - Start connection */ public function stripeConnect(Request $request) { - $tenant = $request->user()->tenant; - if (!$tenant) { + $tenant = $request->user()->tenant ?? null; + if (! $tenant) { return response()->json(['error' => 'Tenant not found'], 404); } @@ -311,7 +499,7 @@ class OAuthController extends Controller $redirectUri = url('/api/v1/oauth/stripe-callback'); $scopes = 'read_write_payments transfers'; - $authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=" . urlencode($redirectUri); + $authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=".urlencode($redirectUri); return redirect($authUrl); } @@ -326,16 +514,15 @@ class OAuthController extends Controller $error = $request->get('error'); if ($error) { - return redirect('/admin')->with('error', 'Stripe connection failed: ' . $error); + return redirect('/admin')->with('error', 'Stripe connection failed: '.$error); } - if (!$code || !$state) { + if (! $code || ! $state) { return redirect('/admin')->with('error', 'Invalid callback parameters'); } - // Validate state $sessionState = session('stripe_state'); - if (!hash_equals($state, $sessionState)) { + if (! hash_equals($state, (string) $sessionState)) { return redirect('/admin')->with('error', 'Invalid state parameter'); } @@ -355,9 +542,9 @@ class OAuthController extends Controller ], ]); - $tokenData = json_decode($response->getBody(), true); + $tokenData = json_decode($response->getBody()->getContents(), true); - if (!isset($tokenData['stripe_user_id'])) { + if (! isset($tokenData['stripe_user_id'])) { return redirect('/admin')->with('error', 'Failed to connect Stripe account'); } @@ -369,8 +556,8 @@ class OAuthController extends Controller session()->forget(['stripe_state', 'tenant_id']); return redirect('/admin')->with('success', 'Stripe account connected successfully'); } catch (\Exception $e) { - Log::error('Stripe OAuth error: ' . $e->getMessage()); - return redirect('/admin')->with('error', 'Connection error: ' . $e->getMessage()); + Log::error('Stripe OAuth error: '.$e->getMessage()); + return redirect('/admin')->with('error', 'Connection error: '.$e->getMessage()); } } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/Tenant/CreditController.php b/app/Http/Controllers/Tenant/CreditController.php index 95504c8..c87927d 100644 --- a/app/Http/Controllers/Tenant/CreditController.php +++ b/app/Http/Controllers/Tenant/CreditController.php @@ -8,18 +8,15 @@ use App\Http\Resources\Tenant\EventPurchaseResource; use App\Models\EventCreditsLedger; use App\Models\EventPurchase; use App\Models\Tenant; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpKernel\Exception\HttpException; class CreditController extends Controller { - public function balance(Request $request) + public function balance(Request $request): JsonResponse { - $user = $request->user(); - if (!$user) { - return response()->json(['message' => 'Unauthenticated'], 401); - } - - $tenant = Tenant::findOrFail($user->tenant_id); + $tenant = $this->resolveTenant($request); return response()->json([ 'balance' => $tenant->event_credits_balance, @@ -29,14 +26,10 @@ class CreditController extends Controller public function ledger(Request $request) { - $user = $request->user(); - if (!$user) { - return response()->json(['message' => 'Unauthenticated'], 401); - } + $tenant = $this->resolveTenant($request); - $tenant = Tenant::findOrFail($user->tenant_id); $ledgers = EventCreditsLedger::where('tenant_id', $tenant->id) - ->orderBy('created_at', 'desc') + ->orderByDesc('created_at') ->paginate(20); return CreditLedgerResource::collection($ledgers); @@ -44,16 +37,100 @@ class CreditController extends Controller public function history(Request $request) { - $user = $request->user(); - if (!$user) { - return response()->json(['message' => 'Unauthenticated'], 401); - } + $tenant = $this->resolveTenant($request); - $tenant = Tenant::findOrFail($user->tenant_id); $purchases = EventPurchase::where('tenant_id', $tenant->id) - ->orderBy('purchased_at', 'desc') + ->orderByDesc('purchased_at') ->paginate(20); return EventPurchaseResource::collection($purchases); } -} \ No newline at end of file + + public function purchase(Request $request): JsonResponse + { + $tenant = $this->resolveTenant($request); + + $data = $request->validate([ + 'package_id' => ['required', 'string', 'max:255'], + 'credits_added' => ['required', 'integer', 'min:1'], + 'platform' => ['nullable', 'string', 'max:32'], + 'transaction_id' => ['nullable', 'string', 'max:255'], + 'subscription_active' => ['sometimes', 'boolean'], + ]); + + $purchase = EventPurchase::create([ + 'tenant_id' => $tenant->id, + 'events_purchased' => $data['credits_added'], + 'amount' => 0, + 'currency' => 'EUR', + 'provider' => $data['platform'] ?? 'tenant-app', + 'external_receipt_id' => $data['transaction_id'] ?? null, + 'status' => 'completed', + 'purchased_at' => now(), + ]); + + $note = 'Package: '.$data['package_id']; + $incremented = $tenant->incrementCredits($data['credits_added'], 'purchase', $note, $purchase->id); + + if (! $incremented) { + throw new HttpException(500, 'Unable to record credit purchase'); + } + + if (array_key_exists('subscription_active', $data)) { + $tenant->update([ + 'subscription_tier' => $data['subscription_active'] ? 'pro' : $tenant->subscription_tier, + ]); + } + + $tenant->refresh(); + + return response()->json([ + 'message' => 'Purchase recorded', + 'balance' => $tenant->event_credits_balance, + 'subscription_active' => (bool) ($data['subscription_active'] ?? false), + ], 201); + } + + public function sync(Request $request): JsonResponse + { + $tenant = $this->resolveTenant($request); + + $data = $request->validate([ + 'balance' => ['nullable', 'integer', 'min:0'], + 'subscription_active' => ['sometimes', 'boolean'], + 'last_sync' => ['nullable', 'date'], + ]); + + if (array_key_exists('subscription_active', $data)) { + $tenant->update([ + 'subscription_tier' => $data['subscription_active'] ? 'pro' : ($tenant->subscription_tier ?? 'free'), + ]); + } + + // Server remains source of truth for balance; echo current state back to client + return response()->json([ + 'balance' => $tenant->event_credits_balance, + 'subscription_active' => (bool) ($tenant->active_subscription ?? false), + 'server_time' => now()->toIso8601String(), + ]); + } + + private function resolveTenant(Request $request): Tenant + { + $user = $request->user(); + if ($user && isset($user->tenant) && $user->tenant instanceof Tenant) { + return $user->tenant; + } + + $tenantId = $request->attributes->get('tenant_id'); + if (! $tenantId && $user && isset($user->tenant_id)) { + $tenantId = $user->tenant_id; + } + + if (! $tenantId) { + throw new HttpException(401, 'Unauthenticated'); + } + + return Tenant::findOrFail($tenantId); + } +} diff --git a/app/Http/Middleware/CreditCheckMiddleware.php b/app/Http/Middleware/CreditCheckMiddleware.php index 4b7ac59..b0d5fec 100644 --- a/app/Http/Middleware/CreditCheckMiddleware.php +++ b/app/Http/Middleware/CreditCheckMiddleware.php @@ -2,23 +2,18 @@ namespace App\Http\Middleware; +use App\Models\Event; +use App\Models\Tenant; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; -use App\Models\Tenant; -use App\Models\Event; class CreditCheckMiddleware { public function handle(Request $request, Closure $next): Response { if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) { - $user = $request->user(); - if (!$user) { - return response()->json(['message' => 'Unauthenticated'], 401); - } - - $tenant = Tenant::findOrFail($user->tenant_id); + $tenant = $this->resolveTenant($request); if ($tenant->event_credits_balance < 1) { return response()->json(['message' => 'Insufficient event credits'], 422); @@ -30,10 +25,28 @@ class CreditCheckMiddleware $event = Event::where('slug', $eventSlug)->firstOrFail(); $tenant = $event->tenant; - // For update, no credit check needed (already consumed on create) $request->merge(['tenant' => $tenant]); } return $next($request); } -} \ No newline at end of file + + private function resolveTenant(Request $request): Tenant + { + $user = $request->user(); + if ($user && isset($user->tenant) && $user->tenant instanceof Tenant) { + return $user->tenant; + } + + $tenantId = $request->attributes->get('tenant_id'); + if (! $tenantId && $user && isset($user->tenant_id)) { + $tenantId = $user->tenant_id; + } + + if (! $tenantId) { + abort(401, 'Unauthenticated'); + } + + return Tenant::findOrFail($tenantId); + } +} diff --git a/app/Http/Middleware/TenantIsolation.php b/app/Http/Middleware/TenantIsolation.php index b4a7e86..9452ea1 100644 --- a/app/Http/Middleware/TenantIsolation.php +++ b/app/Http/Middleware/TenantIsolation.php @@ -27,8 +27,12 @@ class TenantIsolation } // Set tenant context for query scoping - DB::statement("SET @tenant_id = ?", [$tenantId]); - + $connection = DB::connection(); + if (in_array($connection->getDriverName(), ['mysql', 'mariadb'])) { + $connection->statement('SET @tenant_id = ?', [$tenantId]); + } + + // Add tenant context to request for easy access in controllers $request->attributes->set('current_tenant_id', $tenantId); @@ -58,4 +62,4 @@ class TenantIsolation // 4. For tenant-specific resources, use token tenant_id return null; } -} \ No newline at end of file +} diff --git a/app/Http/Middleware/TenantTokenGuard.php b/app/Http/Middleware/TenantTokenGuard.php index f434199..e80d791 100644 --- a/app/Http/Middleware/TenantTokenGuard.php +++ b/app/Http/Middleware/TenantTokenGuard.php @@ -2,14 +2,16 @@ namespace App\Http\Middleware; +use App\Models\Tenant; use App\Models\TenantToken; use Closure; -use Firebase\JWT\Exceptions\TokenExpiredException; use Firebase\JWT\JWT; use Firebase\JWT\Key; +use Illuminate\Auth\GenericUser; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; -use Illuminate\Validation\ValidationException; +use Illuminate\Support\Str; class TenantTokenGuard { @@ -20,7 +22,7 @@ class TenantTokenGuard { $token = $this->getTokenFromRequest($request); - if (!$token) { + if (! $token) { return response()->json(['error' => 'Token not provided'], 401); } @@ -30,25 +32,46 @@ class TenantTokenGuard return response()->json(['error' => 'Invalid token'], 401); } - // Check token blacklist if ($this->isTokenBlacklisted($decoded)) { return response()->json(['error' => 'Token has been revoked'], 401); } - // Validate scopes if specified - if (!empty($scopes) && !$this->hasScopes($decoded, $scopes)) { + if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) { return response()->json(['error' => 'Insufficient scopes'], 403); } - // Check expiration - if ($decoded['exp'] < time()) { - // Add to blacklist on expiry + if (($decoded['exp'] ?? 0) < time()) { $this->blacklistToken($decoded); return response()->json(['error' => 'Token expired'], 401); } - // Set tenant ID on request - $request->merge(['tenant_id' => $decoded['sub']]); + $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null; + if (! $tenantId) { + return response()->json(['error' => 'Invalid token payload'], 401); + } + + $tenant = Tenant::query()->find($tenantId); + if (! $tenant) { + return response()->json(['error' => 'Tenant not found'], 404); + } + + $scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []); + + $principal = new GenericUser([ + 'id' => $tenant->id, + 'tenant_id' => $tenant->id, + 'tenant' => $tenant, + 'scopes' => $scopesFromToken, + 'client_id' => $decoded['client_id'] ?? null, + 'jti' => $decoded['jti'] ?? null, + 'token_type' => $decoded['type'] ?? 'access', + ]); + + Auth::setUser($principal); + $request->setUserResolver(fn () => $principal); + + $request->merge(['tenant_id' => $tenant->id]); + $request->attributes->set('tenant_id', $tenant->id); $request->attributes->set('decoded_token', $decoded); return $next($request); @@ -61,7 +84,7 @@ class TenantTokenGuard { $header = $request->header('Authorization'); - if (str_starts_with($header, 'Bearer ')) { + if (is_string($header) && str_starts_with($header, 'Bearer ')) { return substr($header, 7); } @@ -78,11 +101,12 @@ class TenantTokenGuard private function decodeToken(string $token): array { $publicKey = file_get_contents(storage_path('app/public.key')); - if (!$publicKey) { + if (! $publicKey) { throw new \Exception('JWT public key not found'); } $decoded = JWT::decode($token, new Key($publicKey, 'RS256')); + return (array) $decoded; } @@ -91,23 +115,29 @@ class TenantTokenGuard */ private function isTokenBlacklisted(array $decoded): bool { - $jti = isset($decoded['jti']) ? $decoded['jti'] : md5($decoded['sub'] . $decoded['iat']); + $jti = $decoded['jti'] ?? null; + if (! $jti) { + return false; + } + $cacheKey = "blacklisted_token:{$jti}"; - - // Check cache first (faster) if (Cache::has($cacheKey)) { return true; } - // Check DB blacklist - $dbJti = $decoded['jti'] ?? null; - $blacklisted = TenantToken::where('jti', $dbJti) - ->orWhere('token_hash', md5($decoded['sub'] . $decoded['iat'])) - ->where('expires_at', '>', now()) - ->exists(); + $tokenRecord = TenantToken::query()->where('jti', $jti)->first(); + if (! $tokenRecord) { + return false; + } - if ($blacklisted) { - Cache::put($cacheKey, true, now()->addMinutes(5)); + if ($tokenRecord->revoked_at) { + Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); + return true; + } + + if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) { + $tokenRecord->update(['revoked_at' => now()]); + Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); return true; } @@ -119,25 +149,35 @@ class TenantTokenGuard */ private function blacklistToken(array $decoded): void { - $jti = $decoded['jti'] ?? md5($decoded['sub'] . $decoded['iat']); + $jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '') . ($decoded['iat'] ?? '')); $cacheKey = "blacklisted_token:{$jti}"; - // Cache for immediate effect - Cache::put($cacheKey, true, $decoded['exp'] - time()); + Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); - // Store in DB for persistence - TenantToken::updateOrCreate( - [ - 'jti' => $jti, - 'tenant_id' => $decoded['sub'], - ], - [ - 'token_hash' => md5(json_encode($decoded)), - 'ip_address' => request()->ip(), - 'user_agent' => request()->userAgent(), - 'expires_at' => now()->addHours(24), // Keep for 24h after expiry - ] - ); + $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null; + $tokenType = $decoded['type'] ?? 'access'; + + $record = TenantToken::query()->where('jti', $jti)->first(); + if ($record) { + $record->update([ + 'revoked_at' => now(), + 'expires_at' => $record->expires_at ?? now(), + ]); + return; + } + + if ($tenantId === null) { + return; + } + + TenantToken::create([ + 'id' => (string) Str::uuid(), + 'tenant_id' => $tenantId, + 'jti' => $jti, + 'token_type' => $tokenType, + 'expires_at' => now(), + 'revoked_at' => now(), + ]); } /** @@ -145,18 +185,36 @@ class TenantTokenGuard */ private function hasScopes(array $decoded, array $requiredScopes): bool { - $tokenScopes = $decoded['scopes'] ?? []; - - if (!is_array($tokenScopes)) { - $tokenScopes = explode(' ', $tokenScopes); - } + $tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []); foreach ($requiredScopes as $scope) { - if (!in_array($scope, $tokenScopes)) { + if (! in_array($scope, $tokenScopes, true)) { return false; } } return true; } -} \ No newline at end of file + + private function normaliseScopes(mixed $scopes): array + { + if (is_array($scopes)) { + return array_values(array_filter($scopes, fn ($scope) => $scope !== null && $scope !== '')); + } + + if (is_string($scopes)) { + return array_values(array_filter(explode(' ', $scopes))); + } + + return []; + } + + private function cacheTtlFromDecoded(array $decoded): int + { + $exp = $decoded['exp'] ?? time(); + $ttl = max($exp - time(), 60); + + return $ttl; + } +} + diff --git a/app/Models/OAuthClient.php b/app/Models/OAuthClient.php index 5cd851d..3768c30 100644 --- a/app/Models/OAuthClient.php +++ b/app/Models/OAuthClient.php @@ -14,13 +14,18 @@ class OAuthClient extends Model 'id', 'client_id', 'client_secret', + 'tenant_id', 'redirect_uris', 'scopes', + 'is_active', ]; protected $casts = [ + 'id' => 'string', + 'tenant_id' => 'integer', 'scopes' => 'array', 'redirect_uris' => 'array', + 'is_active' => 'bool', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/Models/OAuthCode.php b/app/Models/OAuthCode.php index ee47922..18c9b13 100644 --- a/app/Models/OAuthCode.php +++ b/app/Models/OAuthCode.php @@ -8,9 +8,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class OAuthCode extends Model { protected $table = 'oauth_codes'; - + + public $timestamps = false; + protected $guarded = []; - + protected $fillable = [ 'id', 'client_id', @@ -22,22 +24,22 @@ class OAuthCode extends Model 'scope', 'expires_at', ]; - + protected $casts = [ 'expires_at' => 'datetime', 'created_at' => 'datetime', ]; - + public function client(): BelongsTo { return $this->belongsTo(OAuthClient::class, 'client_id', 'client_id'); } - + public function user(): BelongsTo { return $this->belongsTo(User::class); } - + public function isExpired(): bool { return $this->expires_at < now(); diff --git a/app/Models/RefreshToken.php b/app/Models/RefreshToken.php index aa3980f..2cd05b4 100644 --- a/app/Models/RefreshToken.php +++ b/app/Models/RefreshToken.php @@ -7,6 +7,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class RefreshToken extends Model { + public $incrementing = false; + protected $keyType = 'string'; + public $timestamps = false; + protected $table = 'refresh_tokens'; protected $guarded = []; @@ -14,6 +18,7 @@ class RefreshToken extends Model protected $fillable = [ 'id', 'tenant_id', + 'client_id', 'token', 'access_token', 'expires_at', diff --git a/app/Models/TenantToken.php b/app/Models/TenantToken.php index b419a3a..0531a45 100644 --- a/app/Models/TenantToken.php +++ b/app/Models/TenantToken.php @@ -6,6 +6,10 @@ use Illuminate\Database\Eloquent\Model; class TenantToken extends Model { + public $incrementing = false; + protected $keyType = 'string'; + public $timestamps = false; + protected $table = 'tenant_tokens'; protected $guarded = []; diff --git a/bootstrap/app.php b/bootstrap/app.php index 53a355a..9911a78 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,8 +1,11 @@ withMiddleware(function (Middleware $middleware) { + $middleware->alias([ + 'tenant.token' => TenantTokenGuard::class, + 'tenant.isolation' => TenantIsolation::class, + 'credit.check' => CreditCheckMiddleware::class, + ]); + $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->web(append: [ diff --git a/database/migrations/2025_09_17_184450_add_tenant_id_to_photos_table_new.php b/database/migrations/2025_09_17_184450_add_tenant_id_to_photos_table_new.php index 0749dcf..d6a60a3 100644 --- a/database/migrations/2025_09_17_184450_add_tenant_id_to_photos_table_new.php +++ b/database/migrations/2025_09_17_184450_add_tenant_id_to_photos_table_new.php @@ -12,9 +12,11 @@ return new class extends Migration public function up(): void { Schema::table('photos', function (Blueprint $table) { - $table->unsignedBigInteger('tenant_id')->nullable()->after('event_id'); - $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); - $table->index('tenant_id'); + if (!Schema::hasColumn('photos', 'tenant_id')) { + $table->unsignedBigInteger('tenant_id')->nullable()->after('event_id'); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + $table->index('tenant_id'); + } }); } @@ -24,8 +26,10 @@ return new class extends Migration public function down(): void { Schema::table('photos', function (Blueprint $table) { - $table->dropForeign(['tenant_id']); - $table->dropColumn('tenant_id'); + if (Schema::hasColumn('photos', 'tenant_id')) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } }); } -}; +}; \ No newline at end of file diff --git a/database/migrations/2025_09_18_170042_add_is_active_to_oauth_clients_table.php b/database/migrations/2025_09_18_170042_add_is_active_to_oauth_clients_table.php new file mode 100644 index 0000000..9860d4c --- /dev/null +++ b/database/migrations/2025_09_18_170042_add_is_active_to_oauth_clients_table.php @@ -0,0 +1,79 @@ +boolean('is_active')->default(true); + }); + } + + $clients = DB::table('oauth_clients')->get(['id', 'scopes', 'redirect_uris', 'is_active']); + + foreach ($clients as $client) { + $scopes = $this->normaliseValue($client->scopes, ['tenant:read', 'tenant:write']); + $redirects = $this->normaliseValue($client->redirect_uris); + + DB::table('oauth_clients') + ->where('id', $client->id) + ->update([ + 'scopes' => $scopes === null ? null : json_encode($scopes), + 'redirect_uris' => $redirects === null ? null : json_encode($redirects), + 'is_active' => $client->is_active ?? true, + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('oauth_clients', 'is_active')) { + Schema::table('oauth_clients', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + } + } + + private function normaliseValue(mixed $value, ?array $fallback = null): ?array + { + if ($value === null) { + return $fallback; + } + + if (is_array($value)) { + return $this->cleanArray($value) ?: $fallback; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $this->cleanArray($decoded) ?: $fallback; + } + + $parts = preg_split('/[\r\n,]+/', $value) ?: []; + return $this->cleanArray($parts) ?: $fallback; + } + + return $fallback; + } + + private function cleanArray(array $items): array + { + $items = array_map(fn ($item) => is_string($item) ? trim($item) : $item, $items); + $items = array_filter($items, fn ($item) => ! ($item === null || $item === '')); + + return array_values($items); + } +}; diff --git a/database/migrations/2025_09_18_180407_add_tenant_id_to_oauth_clients_table.php b/database/migrations/2025_09_18_180407_add_tenant_id_to_oauth_clients_table.php new file mode 100644 index 0000000..d5f54a9 --- /dev/null +++ b/database/migrations/2025_09_18_180407_add_tenant_id_to_oauth_clients_table.php @@ -0,0 +1,36 @@ +foreignId('tenant_id') + ->nullable() + ->after('client_secret') + ->constrained('tenants') + ->nullOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_clients', function (Blueprint $table) { + if (Schema::hasColumn('oauth_clients', 'tenant_id')) { + $table->dropConstrainedForeignId('tenant_id'); + } + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_18_180414_add_client_id_to_refresh_tokens_table.php b/database/migrations/2025_09_18_180414_add_client_id_to_refresh_tokens_table.php new file mode 100644 index 0000000..1f78783 --- /dev/null +++ b/database/migrations/2025_09_18_180414_add_client_id_to_refresh_tokens_table.php @@ -0,0 +1,32 @@ +string('client_id', 255)->nullable()->after('tenant_id')->index(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('refresh_tokens', function (Blueprint $table) { + if (Schema::hasColumn('refresh_tokens', 'client_id')) { + $table->dropColumn('client_id'); + } + }); + } +}; \ No newline at end of file diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index a09436e..d8813f2 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,9 +1,7 @@ -call([ SuperAdminSeeder::class, DemoEventSeeder::class, + OAuthClientSeeder::class, ]); + + if (app()->environment(['local', 'development', 'demo'])) { + $this->call([ + DemoPhotosSeeder::class, + DemoAchievementsSeeder::class, + ]); + } } } diff --git a/database/seeders/DemoAchievementsSeeder.php b/database/seeders/DemoAchievementsSeeder.php new file mode 100644 index 0000000..914e4bf --- /dev/null +++ b/database/seeders/DemoAchievementsSeeder.php @@ -0,0 +1,117 @@ +first(); + $tenant = Tenant::where('slug', 'demo')->first(); + + if (! $event || ! $tenant) { + $this->command?->warn('Demo event/tenant missing – skipping DemoAchievementsSeeder'); + return; + } + + $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; + } + + $sourceFiles = collect(Storage::disk('public')->files('photos')) + ->filter(fn ($path) => Str::endsWith(Str::lower($path), '.jpg')) + ->values(); + + 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], + ]; + + $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), + ]); + } + } + } + + $this->command?->info('Demo achievements seeded.'); + } +} diff --git a/database/seeders/OAuthClientSeeder.php b/database/seeders/OAuthClientSeeder.php new file mode 100644 index 0000000..6fc99e0 --- /dev/null +++ b/database/seeders/OAuthClientSeeder.php @@ -0,0 +1,47 @@ +value('id') + ?? Tenant::query()->orderBy('id')->value('id'); + + $redirectUris = [ + 'http://localhost:5174/auth/callback', + 'http://localhost:8000/auth/callback', + ]; + + $scopes = [ + 'tenant:read', + 'tenant:write', + ]; + + $client = OAuthClient::firstOrNew(['client_id' => $clientId]); + + if (!$client->exists) { + $client->id = (string) Str::uuid(); + } + + $client->fill([ + 'client_secret' => null, // Public client, no secret needed for PKCE + 'tenant_id' => $tenantId, + 'redirect_uris' => $redirectUris, + 'scopes' => $scopes, + 'is_active' => true, + ]); + + $client->save(); + } +} diff --git a/docs/prp/07-guest-pwa-routes-components.md b/docs/prp/07-guest-pwa-routes-components.md index 1de27f6..0f36e99 100644 --- a/docs/prp/07-guest-pwa-routes-components.md +++ b/docs/prp/07-guest-pwa-routes-components.md @@ -1,4 +1,4 @@ -# 07a — Guest PWA Routes & Components +# 07a — Guest PWA Routes & Components This scaffold describes recommended routes, guards, directories, and components for the Guest PWA. It is framework-leaning (React Router v6 + Vite), but adaptable. @@ -8,25 +8,26 @@ Routing Principles - Prefer modal routes (photo detail) layered over the gallery. Route Map (proposed) -- `/` — Landing (QR/PIN input; deep-link handler) -- `/setup` — Profile Setup (name/avatar; skippable) -- `/e/:slug` — Home/Feed (default gallery view + info bar) -- `/e/:slug/tasks` — Task Picker (random/emotion) -- `/e/:slug/tasks/:taskId` — Task Detail (card) -- `/e/:slug/upload` — Upload Picker (camera/library + tagging) -- `/e/:slug/queue` — Upload Queue (progress/retry) -- `/e/:slug/gallery` — Gallery index (alias of Home or dedicated page) -- `/e/:slug/photo/:photoId` — Photo Lightbox (modal over gallery) -- `/e/:slug/achievements` — Achievements (optional) -- `/e/:slug/slideshow` — Slideshow (optional, read-only) -- `/settings` — Settings (language/theme/cache/legal) -- `/legal/:page` — Legal pages (imprint/privacy/terms) -- `*` — NotFound +- `/` — Landing (QR/PIN input; deep-link handler) +- `/setup` — Profile Setup (name/avatar; skippable) +- `/e/:slug` — Home/Feed (default gallery view + info bar) +- `/e/:slug/tasks` — Task Picker (random/emotion) +- `/e/:slug/tasks/:taskId` — Task Detail (card) +- `/e/:slug/upload` — Upload Picker (camera/library + tagging) +- `/e/:slug/queue` — Upload Queue (progress/retry) +- `/e/:slug/gallery` — Gallery index (alias of Home or dedicated page) +- `/e/:slug/photo/:photoId` — Photo Lightbox (modal over gallery) +- `/e/:slug/achievements` — Achievements (optional) +- `/e/:slug/slideshow` — Slideshow (optional, read-only) +- `/legal/:page` — Legal pages (imprint/privacy/terms) +- `*` — NotFound + +Note: The settings experience is handled via the header sheet (no dedicated route; legal pages stay routable under /legal/:page). Guards & Loaders -- `EventGuard` — verifies event token in storage; attempts refresh; otherwise redirects to `/`. -- `PrefetchEvent` — loads event metadata/theme on `:slug` routes. -- `OfflineFallback` — surfaces offline banner and queues mutations. +- `EventGuard` — verifies event token in storage; attempts refresh; otherwise redirects to `/`. +- `PrefetchEvent` — loads event metadata/theme on `:slug` routes. +- `OfflineFallback` — surfaces offline banner and queues mutations. Suggested Directory Structure ``` @@ -46,7 +47,7 @@ apps/guest-pwa/ PhotoLightbox.tsx // modal route AchievementsPage.tsx SlideshowPage.tsx - SettingsPage.tsx + SettingsSheet.tsx LegalPage.tsx NotFoundPage.tsx components/ @@ -116,7 +117,7 @@ export const router = createBrowserRouter([ { path: 'slideshow', element: }, ], }, - { path: '/settings', element: }, + // Settings sheet is rendered inside Header; no standalone route. { path: '/legal/:page', element: }, { path: '*', element: }, ]); @@ -124,11 +125,13 @@ export const router = createBrowserRouter([ Component Checklist - Layout - - `Header`, `InfoBar` (X Gäste online • Y Aufgaben gelöst), `BottomNav`, `Toast`. + - `Header`, `InfoBar` (X Gäste online • Y Aufgaben gelöst), `BottomNav`, `Toast`. - Entry - `QRPinForm` (QR deep link or PIN fallback), `ProfileForm` (name/avatar). - Home/Feed - - `CTAButtons` (Random Task, Emotion Picker, Quick Photo), `GalleryMasonry`, `FiltersBar`, `PhotoCard`. + - `HeroCard` (Willkommensgruess + Eventtitel) und `StatTiles` (online Gaeste, geloeste Aufgaben, letztes Upload). + - `CTAButtons` (Aufgabe ziehen, Direkt-Upload, Galerie) + `UploadQueueLink` fuer Warteschlange. + - `EmotionPickerGrid` und `GalleryPreview` als inhaltlicher Einstieg. - Tasks - `EmotionPickerGrid`, `TaskCard` (shows duration, group size, actions). - Capture/Upload (photos only) @@ -136,7 +139,7 @@ Component Checklist - Photo View - `PhotoLightbox` (modal), like/share controls, emotion tags. - Settings & Legal - - `SettingsPage` sections, `LegalPage` renderer. + - `SettingsSheet` (Header-Overlay mit Namenseditor, eingebetteten Rechtsdokumenten, Cache-Leeren), `LegalPage` Renderer. State & Data - TanStack Query for server data (events, photos); optimistic updates for likes. diff --git a/docs/prp/07-guest-pwa.md b/docs/prp/07-guest-pwa.md index 64705fc..df2dc46 100644 --- a/docs/prp/07-guest-pwa.md +++ b/docs/prp/07-guest-pwa.md @@ -1,4 +1,4 @@ -# 07 — Guest PWA +# 07 — Guest PWA Goal - Delight guests with a frictionless, installable photo experience that works offline, respects privacy, and requires no account. @@ -7,13 +7,13 @@ Non-Goals (MVP) - No comments or chat. No facial recognition. No public profiles. No videos. Personas -- Guest (attendee) — scans QR, uploads photos, browses and likes. -- Host (tenant) — optionally shares event PIN with guests; moderates via Tenant Admin PWA. +- Guest (attendee) — scans QR, uploads photos, browses and likes. +- Host (tenant) — optionally shares event PIN with guests; moderates via Tenant Admin PWA. Top Journeys -- Join: Scan QR → Open event → Accept terms → Optional PIN → Land on Gallery. -- Upload: Add photos → Review → Submit → Background upload → Success/Retry. -- Browse: Infinite gallery → Filter (emotion/task) → Open photo → Like/Share → Back. +- Join: Scan QR → Open event → Accept terms → Optional PIN → Land on Gallery. +- Upload: Add photos → Review → Submit → Background upload → Success/Retry. +- Browse: Infinite gallery → Filter (emotion/task) → Open photo → Like/Share → Back. Core Features - Event access @@ -25,14 +25,14 @@ Core Features - Capture & upload - Choose from camera or library; limit file size; show remaining upload cap. - Client-side resize to sane max (e.g., 2560px longest edge); EXIF stripped client-side if available. - - Assign optional emotion/task before submit; default to “Uncategorized”. + - Assign optional emotion/task before submit; default to “Uncategorized”. - Gallery - Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe. - Like (heart) with optimistic UI; share system sheet (URL to CDN variant). - Filters: emotion, featured, mine (local-only tag for items uploaded from this device). - Safety & abuse controls - Rate limits per device and IP; content-length checks; mime/type sniffing. - - Upload moderation state: pending → approved/hidden; show local status. + - Upload moderation state: pending → approved/hidden; show local status. - Privacy & legal - First run shows legal links (imprint/privacy); consent for push if enabled. - No PII stored; guest name is optional free text and not required by default. @@ -44,7 +44,7 @@ Screens - Upload Picker: camera/library, selection preview, emotion/task tagging. - Upload Queue: items with progress, retry, remove; background sync toggle. - Photo Lightbox: zoom, like, share; show emotion tags. -- Settings: language, theme (system), clear cache, legal pages. +- Settings Sheet: Gear-Icon im Header oeffnet eine Sheet-Ansicht mit editierbarem Gastnamen, eingebetteten Rechtsdokumenten (inkl. Zurueck-Navigation) und Cache-Leeren; Theme-Umschalter bleibt im Header. Wireframes - See wireframes file at docs/wireframes/guest-pwa.md for low-fidelity layouts and flows. @@ -55,28 +55,27 @@ Core Pages (Pflichtseiten) - UI: Single input (QR deep link preferred, fallback PIN field) and Join button. - States: invalid/expired token, event closed, offline (allow PIN entry and queue attempt). - Profil-Setup (Name/Avatar) - - Purpose: Optional personalization for likes/attribution. - - UI: Name text field, optional avatar picker; one-time before first entry to event. - - Behavior: Skippable; editable later in Settings. + - Purpose: Optional personalisation fuer Likes/Statistiken; Name wird lokal im Browser gespeichert. + - UI: Name-Feld mit Sofort-Validierung; Avatar folgt spaeter. + - Behavior: Nicht verpflichtend, aber empfohlen; Name kann jederzeit im Settings Sheet angepasst oder geloescht werden. - Startseite (Home/Feed) - - Purpose: Central hub; show event title/subheadline and CTAs. - - Header: Event name + subheadline (e.g., “Dein Fotospiel zur Hochzeit”). - - Info bar: “X Gäste online • Y Aufgaben gelöst”. - - CTAs: „Aufgabe ziehen“ (random), „Wie fühlst du dich?“ (emotion picker), small link “Einfach ein Foto machen”. - - Content: Gallery/Feed with photos + likes. + - Purpose: Central hub; begruesst Gaeste mit ihrem hinterlegten Namen und fuehrt zu den wichtigsten Aktionen. + - Header: Eventtitel plus Live-Kennzahlen (online Gaeste, geloeste Aufgaben); hero-card zeigt "Hey {Name}!". + - Highlights: Drei CTA-Karten fuer Aufgabe ziehen, Direkt-Upload und Galerie sowie ein Button fuer die Upload-Warteschlange. + - Content: EmotionPicker und GalleryPreview bilden weiterhin den Einstieg in Spielstimmung und aktuelle Fotos. - Aufgaben-Flow - Aufgaben-Picker: Choose random task or emotion mood. - Aufgaben-Detail (Karte): Task text, emoji tag, estimated duration, suggested group size; actions: take photo, new task (same mood), change mood. - Foto-Interaktion - Kamera/Upload: Capture or pick; progress + success message on completion; background sync. - - Galerie/Übersicht: Grid/Feed; filters: Neueste, Beliebt, Meine; Like hearts. + - Galerie/Übersicht: Grid/Feed; filters: Neueste, Beliebt, Meine; Like hearts. - Foto-Detailansicht: Fullscreen; likes/reactions; linked task + (optional) uploader name. - Motivation & Spiel - Achievements/Erfolge: Badges (e.g., Erstes Foto, 5 Aufgaben, Beliebtestes Foto); personal progress. -- Optionale Ergänzungen - - Slideshow/Präsentationsmodus: Auto-rotating gallery for TV/Beamer with likes/task overlay. - - Onboarding: 1–2 “So funktioniert das Spiel” hints the first time. - - Event-Abschluss: “Danke fürs Mitmachen”, summary stats, link/QR to online gallery. +- Optionale Ergänzungen + - Slideshow/Präsentationsmodus: Auto-rotating gallery for TV/Beamer with likes/task overlay. + - Onboarding: 1–2 “So funktioniert das Spiel” hints the first time. + - Event-Abschluss: “Danke fürs Mitmachen”, summary stats, link/QR to online gallery. Technical Notes - Installability: PWA manifest + service worker; prompt A2HS on supported browsers. @@ -84,15 +83,15 @@ Technical Notes - Background Sync: use Background Sync API when available; fallback to retry on app open. - Accessibility: large tap targets, high contrast, keyboard support, reduced motion. - i18n: default `de`, fallback `en`; all strings in locale files; RTL not in MVP. -- Media types: Photos only (no videos) — decision locked for MVP and v1. +- Media types: Photos only (no videos) — decision locked for MVP and v1. - Realtime model: periodic polling (no WebSockets). Home counters every 10s; gallery delta every 30s with exponential backoff when tab hidden or offline. API Touchpoints -- GET `/api/v1/events/{slug}` — public event metadata (when open) + theme. -- GET `/api/v1/events/{slug}/photos` — paginated gallery (approved only). -- POST `/api/v1/events/{slug}/photos` — signed upload initiation; returns URL + fields. -- POST (S3) — direct upload to object storage; then backend finalize call. -- POST `/api/v1/photos/{id}/like` — idempotent like with device token. +- GET `/api/v1/events/{slug}` — public event metadata (when open) + theme. +- GET `/api/v1/events/{slug}/photos` — paginated gallery (approved only). +- POST `/api/v1/events/{slug}/photos` — signed upload initiation; returns URL + fields. +- POST (S3) — direct upload to object storage; then backend finalize call. +- POST `/api/v1/photos/{id}/like` — idempotent like with device token. Limits (MVP defaults) - Max uploads per device per event: 50 @@ -100,10 +99,10 @@ Limits (MVP defaults) - Max resolution: 2560px longest edge per photo Edge Cases -- Token expired/invalid → Show “Event closed/invalid link”; link to retry. -- No connectivity → Queue actions; show badge; retry policy with backoff. -- Storage full → Offer to clear cache or deselect files. -- Permission denied (camera/photos) → Explain and offer system shortcut. +- Token expired/invalid → Show “Event closed/invalid link”; link to retry. +- No connectivity → Queue actions; show badge; retry policy with backoff. +- Storage full → Offer to clear cache or deselect files. +- Permission denied (camera/photos) → Explain and offer system shortcut. Decisions - Videos are not supported (capture/upload strictly photos). diff --git a/docs/prp/13-backend-authentication.md b/docs/prp/13-backend-authentication.md index baeda85..1c8337b 100644 --- a/docs/prp/13-backend-authentication.md +++ b/docs/prp/13-backend-authentication.md @@ -7,7 +7,7 @@ This document outlines the authentication requirements and implementation detail ## Authentication Flow ### 1. Authorization Request -- **Endpoint**: `POST /oauth/authorize` +- **Endpoint**: `GET /api/v1/oauth/authorize` - **Method**: GET (redirect from frontend) - **Parameters**: - `client_id`: Fixed client ID for tenant-admin-app (`tenant-admin-app`) @@ -21,7 +21,7 @@ This document outlines the authentication requirements and implementation detail **Response**: Redirect to frontend with authorization code and state parameters. ### 2. Token Exchange -- **Endpoint**: `POST /oauth/token` +- **Endpoint**: `POST /api/v1/oauth/token` - **Method**: POST - **Content-Type**: `application/x-www-form-urlencoded` - **Parameters**: @@ -44,7 +44,7 @@ This document outlines the authentication requirements and implementation detail ``` ### 3. Token Refresh -- **Endpoint**: `POST /oauth/token` +- **Endpoint**: `POST /api/v1/oauth/token` - **Method**: POST - **Content-Type**: `application/x-www-form-urlencoded` - **Parameters**: @@ -102,6 +102,19 @@ This document outlines the authentication requirements and implementation detail ### oauth_clients Table ```sql +CREATE TABLE oauth_clients ( + id VARCHAR(255) PRIMARY KEY, + client_id VARCHAR(255) UNIQUE NOT NULL, + client_secret VARCHAR(255), + tenant_id BIGINT UNSIGNED NULL, + redirect_uris JSON NULL, + scopes JSON NULL, + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT oauth_clients_tenant_id_foreign FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE SET NULL +); +```sql CREATE TABLE oauth_clients ( id VARCHAR(255) PRIMARY KEY, client_id VARCHAR(255) UNIQUE NOT NULL, @@ -133,6 +146,20 @@ CREATE TABLE oauth_codes ( ### refresh_tokens Table ```sql +CREATE TABLE refresh_tokens ( + id VARCHAR(255) PRIMARY KEY, + tenant_id VARCHAR(255) NOT NULL, + client_id VARCHAR(255), + token VARCHAR(255) UNIQUE NOT NULL, + access_token VARCHAR(255), + scope TEXT, + ip_address VARCHAR(45), + user_agent TEXT, + expires_at TIMESTAMP, + revoked_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +```sql CREATE TABLE refresh_tokens ( id VARCHAR(255) PRIMARY KEY, tenant_id VARCHAR(255) NOT NULL, @@ -261,18 +288,22 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app - Revoke old refresh token immediately - Limit refresh tokens per tenant to 5 active -### 3. Rate Limiting +### 3. Key Management +- RSA key pairs for signing are generated on demand and stored in storage/app/private.key (private) and storage/app/public.key (public). +- Treat the private key as a secret; rotate it alongside deploys that invalidate tenant tokens. + +### 4. Rate Limiting - Authorization requests: 10/minute per IP - Token exchanges: 5/minute per IP - Token validation: 100/minute per tenant -### 4. Logging and Monitoring +### 5. Logging and Monitoring - Log all authentication attempts (success/failure) - Monitor token usage patterns - Alert on unusual activity (multiple failed attempts, token anomalies) - Track refresh token usage for security analysis -### 5. Database Cleanup +### 6. Database Cleanup - Cron job to remove expired authorization codes (daily) - Remove expired refresh tokens (weekly) - Clean blacklisted tokens after expiry (daily) @@ -315,4 +346,10 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app - Alert on PKCE validation failures - Log all security-related events -This implementation provides secure, scalable authentication for the Fotospiel tenant system, following OAuth2 best practices with PKCE for public clients. \ No newline at end of file +This implementation provides secure, scalable authentication for the Fotospiel tenant system, following OAuth2 best practices with PKCE for public clients. + + + + + + diff --git a/docs/prp/tenant-app-specs/api-usage.md b/docs/prp/tenant-app-specs/api-usage.md index 913091c..6ce73d9 100644 --- a/docs/prp/tenant-app-specs/api-usage.md +++ b/docs/prp/tenant-app-specs/api-usage.md @@ -1,4 +1,4 @@ -# API-Nutzung der Tenant Admin App +# API-Nutzung der Tenant Admin App Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit der Backend-Hauptapp kommuniziert. Alle Requests sind tenant-scoped und erfordern JWT-Authentifizierung. @@ -19,6 +19,7 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit - **Response**: Neuer Access/Refresh-Token - **Token Validation**: `GET /api/v1/tenant/me` + - **Redirect URI**: Standardmaessig `${origin}/admin/auth/callback` (per Vite-Env anpassbar) - **Headers**: `Authorization: Bearer {access_token}` - **Response**: `{ id, email, tenant_id, role, name }` @@ -51,18 +52,18 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit - **Validierung**: Prüft Credit-Balance (1 Credit pro Event) ### Event-Details -- **GET /api/v1/tenant/events/{id}** +- **GET /api/v1/tenant/events/{slug}** - **Headers**: `Authorization: Bearer {token}` - **Response**: Erweitertes Event mit `{ tasks[], members, stats { likes, views, uploads } }` ### Event updaten -- **PATCH /api/v1/tenant/events/{id}** +- **PATCH /api/v1/tenant/events/{slug}** - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`, `If-Match: {etag}` - **Body**: Partial Event-Daten (title, date, location, description) - **Response**: Updated Event ### Event archivieren -- **DELETE /api/v1/tenant/events/{id}** +- **DELETE /api/v1/tenant/events/{slug}** - **Headers**: `Authorization: Bearer {token}`, `If-Match: {etag}` - **Response**: 204 No Content (soft-delete) @@ -156,12 +157,26 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit - **Response**: `{ balance: number }` ### Ledger-Verlauf -- **GET /api/v1/tenant/ledger** +- **GET /api/v1/tenant/credits/ledger** - **Headers**: `Authorization: Bearer {token}` - **Params**: `page`, `per_page` (Pagination) - **Response**: `{ data: LedgerEntry[], current_page, last_page }` - **LedgerEntry**: `{ id, type, amount, credits, date, description, transactionId? }` +### Credits kaufen (In-App) +- **POST /api/v1/tenant/credits/purchase** + - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json` + - **Body**: `{ package_id: string, credits_added: number, platform?: 'capacitor'|'web', transaction_id?: string, subscription_active?: boolean }` + - **Response**: `{ message, balance, subscription_active }` + - **Hinweis**: Wird nach erfolgreichen In-App-Kuferfolgen aufgerufen, aktualisiert Balance & Ledger. + +### Credits synchronisieren +- **POST /api/v1/tenant/credits/sync** + - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json` + - **Body**: `{ balance: number, subscription_active: boolean, last_sync: ISODateString }` + - **Response**: `{ balance, subscription_active, server_time }` + - **Hinweis**: Client meldet lokalen Stand; Server gibt Quelle-der-Wahrheit zurcck. + ### Kauf-Intent erstellen - **POST /api/v1/tenant/purchases/intent** - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json` @@ -242,8 +257,9 @@ curl -H "Authorization: Bearer {token}" \ ## Deployment ### Environment-Variablen -- **REACT_APP_API_URL**: Backend-API-URL (Pflicht) -- **REACT_APP_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht) +- **VITE_API_URL**: Backend-API-URL (Pflicht) +- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht) +- **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat) ### Build & Deploy 1. **Development**: `npm run dev` @@ -254,4 +270,5 @@ curl -H "Authorization: Bearer {token}" \ - App ist PWA-fähig (Manifest, Service Worker). - Installierbar auf Desktop/Mobile via "Zum Startbildschirm hinzufügen". -Für weitere Details siehe die spezifischen Dokumentationsdateien. \ No newline at end of file +Für weitere Details siehe die spezifischen Dokumentationsdateien. + diff --git a/docs/screenshots/1start.png b/docs/screenshots/1start.png new file mode 100644 index 0000000..8c9eb0f Binary files /dev/null and b/docs/screenshots/1start.png differ diff --git a/docs/screenshots/2start.png b/docs/screenshots/2start.png new file mode 100644 index 0000000..782c381 Binary files /dev/null and b/docs/screenshots/2start.png differ diff --git a/docs/screenshots/3emotionpicker.png b/docs/screenshots/3emotionpicker.png new file mode 100644 index 0000000..8701eac Binary files /dev/null and b/docs/screenshots/3emotionpicker.png differ diff --git a/docs/screenshots/4-taskscreen.png b/docs/screenshots/4-taskscreen.png new file mode 100644 index 0000000..bac3ce3 Binary files /dev/null and b/docs/screenshots/4-taskscreen.png differ diff --git a/docs/screenshots/5-camerapage.png b/docs/screenshots/5-camerapage.png new file mode 100644 index 0000000..40bf76c Binary files /dev/null and b/docs/screenshots/5-camerapage.png differ diff --git a/docs/screenshots/6-general-landing-page.png b/docs/screenshots/6-general-landing-page.png new file mode 100644 index 0000000..2669ab1 Binary files /dev/null and b/docs/screenshots/6-general-landing-page.png differ diff --git a/docs/screenshots/7-event-landing-page.png b/docs/screenshots/7-event-landing-page.png new file mode 100644 index 0000000..fa0554e Binary files /dev/null and b/docs/screenshots/7-event-landing-page.png differ diff --git a/docs/wireframes/guest-pwa.md b/docs/wireframes/guest-pwa.md index 17221fc..69ff88c 100644 --- a/docs/wireframes/guest-pwa.md +++ b/docs/wireframes/guest-pwa.md @@ -12,13 +12,15 @@ flowchart TD E --> F[Upload Queue] D --> G[Photo Lightbox] D --> H[Filters] - D --> I[Settings] + D --> I[Settings Sheet] F --> D G --> D ``` Gallery (mobile) +Settings open in-place via a sheet launched from the header gear; the sheet focuses on legal text and cache management while the theme toggle remains in the header. + ``` +--------------------------------------------------+ | Toolbar: [Scan?] [Filter] [Upload] [Menu] | diff --git a/package-lock.json b/package-lock.json index 3f70703..e282a84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "fotospiel-app", "lockfileVersion": 3, "requires": true, "packages": { @@ -7,6 +7,7 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@inertiajs/react": "^2.1.0", + "@playwright/mcp": "^0.0.37", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -28,6 +29,7 @@ "clsx": "^2.1.1", "concurrently": "^9.0.1", "globals": "^15.14.0", + "html5-qrcode": "^2.3.8", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", "playwright": "^1.55.0", @@ -43,6 +45,7 @@ "devDependencies": { "@eslint/js": "^9.19.0", "@laravel/vite-plugin-wayfinder": "^0.1.3", + "@playwright/test": "^1.55.0", "@types/node": "^22.13.5", "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.1", @@ -1773,6 +1776,78 @@ "dev": true, "license": "MIT" }, + "node_modules/@playwright/mcp": { + "version": "0.0.37", + "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.37.tgz", + "integrity": "sha512-BnI2Ijim1rhIGhoFKJRCa+MaWtNr7M2lnLeDldDsR0n+ZB2G7zjt+MAMqy5eRD/mMiWsTaQsXlzZmXeixqBdsA==", + "dependencies": { + "playwright": "1.56.0-alpha-2025-09-06", + "playwright-core": "1.56.0-alpha-2025-09-06" + }, + "bin": { + "mcp-server-playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/mcp/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/@playwright/mcp/node_modules/playwright": { + "version": "1.56.0-alpha-2025-09-06", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0-alpha-2025-09-06.tgz", + "integrity": "sha512-suVjiF5eeUtIqFq5E/5LGgkV0/bRSik87N+M7uLsjPQrKln9QHbZt3cy7Zybicj3ZqTBWWHvpN9b4cnpg6hS0g==", + "dependencies": { + "playwright-core": "1.56.0-alpha-2025-09-06" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@playwright/mcp/node_modules/playwright-core": { + "version": "1.56.0-alpha-2025-09-06", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0-alpha-2025-09-06.tgz", + "integrity": "sha512-B2s/cuqYuu+mT4hIHG8gIOXjCSKh0Np1gJNCp0CrDk/UTLB74gThwXiyPAJU0fADIQH6Dv1glv8ZvKTDVT8Fng==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5991,6 +6066,12 @@ "dev": true, "license": "MIT" }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -8818,7 +8899,6 @@ "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.3.1.tgz", "integrity": "sha512-sgai5gahy/TiyTiqJEwIFpAuPhmkpt7sGVdRfcmNH53Yc3yI57+zFVmIaqbTST0jP/7tSqZuI0aSllXL2HIw5w==", "dev": true, - "license": "MIT", "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", diff --git a/package.json b/package.json index 69da078..5c8b141 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "devDependencies": { "@eslint/js": "^9.19.0", "@laravel/vite-plugin-wayfinder": "^0.1.3", + "@playwright/test": "^1.55.0", "@types/node": "^22.13.5", "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.1", @@ -27,6 +28,7 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@inertiajs/react": "^2.1.0", + "@playwright/mcp": "^0.0.37", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -48,6 +50,7 @@ "clsx": "^2.1.1", "concurrently": "^9.0.1", "globals": "^15.14.0", + "html5-qrcode": "^2.3.8", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", "playwright": "^1.55.0", diff --git a/playwright-report/data/346c13d5b5efe55fbc31bac706e3819995351c5a.webm b/playwright-report/data/346c13d5b5efe55fbc31bac706e3819995351c5a.webm new file mode 100644 index 0000000..4fe4093 Binary files /dev/null and b/playwright-report/data/346c13d5b5efe55fbc31bac706e3819995351c5a.webm differ diff --git a/playwright-report/data/3f9362498fef5095ac418b481173c877d2003abf.md b/playwright-report/data/3f9362498fef5095ac418b481173c877d2003abf.md new file mode 100644 index 0000000..48fb67e --- /dev/null +++ b/playwright-report/data/3f9362498fef5095ac418b481173c877d2003abf.md @@ -0,0 +1,36 @@ +# Page snapshot + +```yaml +- generic [ref=e4]: + - generic [ref=e5]: + - link [ref=e6] [cursor=pointer]: + - /url: https://laravel.com + - img [ref=e7] [cursor=pointer] + - img [ref=e9] + - link [ref=e11] [cursor=pointer]: + - /url: https://vitejs.dev + - img [ref=e12] [cursor=pointer] + - generic [ref=e15]: + - generic [ref=e16]: + - paragraph [ref=e17]: This is the Vite development server that provides Hot Module Replacement for your Laravel application. + - paragraph [ref=e18]: To access your Laravel application, you will need to run a local development server. + - heading "Artisan Serve" [level=2] [ref=e19]: + - link "Artisan Serve" [ref=e20] [cursor=pointer]: + - /url: https://laravel.com/docs/installation#your-first-laravel-project + - paragraph [ref=e21]: Laravel's local development server powered by PHP's built-in web server. + - heading "Laravel Sail" [level=2] [ref=e22]: + - link "Laravel Sail" [ref=e23] [cursor=pointer]: + - /url: https://laravel.com/docs/sail + - paragraph [ref=e24]: A light-weight command-line interface for interacting with Laravel's default Docker development environment. + - generic [ref=e25]: + - paragraph [ref=e26]: + - text: Your Laravel application's configured + - code [ref=e27]: APP_URL + - text: "is:" + - link "http://localhost:8000" [ref=e28] [cursor=pointer]: + - /url: http://localhost:8000 + - paragraph [ref=e29]: Want more information on Laravel's Vite integration? + - paragraph [ref=e30]: + - link "Read the docs →" [ref=e31] [cursor=pointer]: + - /url: https://laravel.com/docs/vite +``` \ No newline at end of file diff --git a/playwright-report/data/85e55eb23cc05de7d445e0361a6021c61dae8afe.webm b/playwright-report/data/85e55eb23cc05de7d445e0361a6021c61dae8afe.webm new file mode 100644 index 0000000..ce133f9 Binary files /dev/null and b/playwright-report/data/85e55eb23cc05de7d445e0361a6021c61dae8afe.webm differ diff --git a/playwright-report/data/ab8955fe4c74a2c8835d79a54de138dea7be6493.png b/playwright-report/data/ab8955fe4c74a2c8835d79a54de138dea7be6493.png new file mode 100644 index 0000000..a665ba0 Binary files /dev/null and b/playwright-report/data/ab8955fe4c74a2c8835d79a54de138dea7be6493.png differ diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..463556d --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,76 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..94e1ad8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://playwright.dev/docs/test-configuration#launching-the-player + */ +function getDisplayValue(value: string | undefined) { + return value === undefined ? '1' : value; +} + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:8000', + trace: 'on-first-retry', + headless: true, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); \ No newline at end of file diff --git a/public/guest-sw.js b/public/guest-sw.js index 30aef4d..033080f 100644 --- a/public/guest-sw.js +++ b/public/guest-sw.js @@ -7,25 +7,52 @@ self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); +const ASSETS_CACHE = 'guest-assets-v1'; +const IMAGES_CACHE = 'guest-images-v1'; + self.addEventListener('fetch', (event) => { const req = event.request; - if (req.method !== 'GET' || !req.url.startsWith(self.location.origin)) return; - event.respondWith((async () => { - const cache = await caches.open('guest-runtime'); - const cached = await cache.match(req); - if (cached) return cached; - try { - const res = await fetch(req); - // Cache static assets and images - const ct = res.headers.get('content-type') || ''; - if (res.ok && (ct.includes('text/css') || ct.includes('javascript') || ct.startsWith('image/'))) { - cache.put(req, res.clone()); + if (req.method !== 'GET') return; + + const url = new URL(req.url); + // Only handle same-origin requests + if (url.origin !== self.location.origin) return; + + // Never cache API calls; let them hit network directly + if (url.pathname.startsWith('/api/')) return; + + // Cache-first for images + if (req.destination === 'image' || /\.(png|jpg|jpeg|webp|avif|gif|svg)(\?.*)?$/i.test(url.pathname)) { + event.respondWith((async () => { + const cache = await caches.open(IMAGES_CACHE); + const cached = await cache.match(req); + if (cached) return cached; + try { + const res = await fetch(req, { credentials: 'same-origin' }); + if (res.ok) cache.put(req, res.clone()); + return res; + } catch (e) { + return cached || Response.error(); } - return res; - } catch (e) { - return cached || new Response('Offline', { status: 503 }); - } - })()); + })()); + return; + } + + // Stale-while-revalidate for CSS/JS assets + if (req.destination === 'style' || req.destination === 'script') { + event.respondWith((async () => { + const cache = await caches.open(ASSETS_CACHE); + const cached = await cache.match(req); + const networkPromise = fetch(req, { credentials: 'same-origin' }) + .then((res) => { + if (res.ok) cache.put(req, res.clone()); + return res; + }) + .catch(() => null); + return cached || (await networkPromise) || Response.error(); + })()); + return; + } }); self.addEventListener('sync', (event) => { diff --git a/redirect_uris b/redirect_uris new file mode 100644 index 0000000..e69de29 diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index f316e0e..bff136f 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -1,98 +1,103 @@ -export async function login(email: string, password: string): Promise<{ token: string }> { - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); - const res = await fetch('/api/v1/tenant/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': csrfToken || '', - }, - body: JSON.stringify({ email, password }), - }); - if (!res.ok) throw new Error('Login failed'); - return res.json(); +import { authorizedFetch } from './auth/tokens'; + +type JsonValue = Record; + +async function jsonOrThrow(response: Response, message: string): Promise { + if (!response.ok) { + const body = await safeJson(response); + console.error('[API]', message, response.status, body); + throw new Error(message); + } + return (await response.json()) as T; +} + +async function safeJson(response: Response): Promise { + try { + return (await response.clone().json()) as JsonValue; + } catch { + return null; + } } export async function getEvents(): Promise { - const token = localStorage.getItem('ta_token') || ''; - const res = await fetch('/api/v1/tenant/events', { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) throw new Error('Failed to load events'); - const json = await res.json(); - return json.data ?? []; + const response = await authorizedFetch('/api/v1/tenant/events'); + const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load events'); + return data.data ?? []; } export async function createEvent(payload: { name: string; slug: string; date?: string; is_active?: boolean }): Promise { - const token = localStorage.getItem('ta_token') || ''; - const res = await fetch('/api/v1/tenant/events', { + const response = await authorizedFetch('/api/v1/tenant/events', { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (!res.ok) throw new Error('Failed to create event'); - const json = await res.json(); - return json.id; + const data = await jsonOrThrow<{ id: number }>(response, 'Failed to create event'); + return data.id; } -export async function updateEvent(id: number, payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>): Promise { - const token = localStorage.getItem('ta_token') || ''; - const res = await fetch(`/api/v1/tenant/events/${id}`, { +export async function updateEvent( + id: number, + payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }> +): Promise { + const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (!res.ok) throw new Error('Failed to update event'); + if (!response.ok) { + await safeJson(response); + throw new Error('Failed to update event'); + } } export async function getEventPhotos(id: number): Promise { - const token = localStorage.getItem('ta_token') || ''; - const res = await fetch(`/api/v1/tenant/events/${id}/photos`, { headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) throw new Error('Failed to load photos'); - const json = await res.json(); - return json.data ?? []; + const response = await authorizedFetch(`/api/v1/tenant/events/${id}/photos`); + const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load photos'); + return data.data ?? []; } -export async function featurePhoto(id: number) { - const token = localStorage.getItem('ta_token') || ''; - await fetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); +export async function featurePhoto(id: number): Promise { + const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST' }); + if (!response.ok) { + await safeJson(response); + throw new Error('Failed to feature photo'); + } } -export async function unfeaturePhoto(id: number) { - const token = localStorage.getItem('ta_token') || ''; - await fetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); +export async function unfeaturePhoto(id: number): Promise { + const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST' }); + if (!response.ok) { + await safeJson(response); + throw new Error('Failed to unfeature photo'); + } } export async function getEvent(id: number): Promise { - const token = localStorage.getItem('ta_token') || ''; - const res = await fetch(`/api/v1/tenant/events/${id}`, { headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) throw new Error('Failed to load event'); - return res.json(); + const response = await authorizedFetch(`/api/v1/tenant/events/${id}`); + return jsonOrThrow(response, 'Failed to load event'); } export async function toggleEvent(id: number): Promise { - const token = localStorage.getItem('ta_token') || ''; - const res = await fetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) throw new Error('Failed to toggle'); - const json = await res.json(); - return !!json.is_active; + const response = await authorizedFetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST' }); + const data = await jsonOrThrow<{ is_active: boolean }>(response, 'Failed to toggle event'); + return !!data.is_active; } export async function getEventStats(id: number): Promise<{ total: number; featured: number; likes: number }> { - const token = localStorage.getItem('ta_token') || ''; - const res = await fetch(`/api/v1/tenant/events/${id}/stats`, { headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) throw new Error('Failed to load stats'); - return res.json(); + const response = await authorizedFetch(`/api/v1/tenant/events/${id}/stats`); + return jsonOrThrow<{ total: number; featured: number; likes: number }>(response, 'Failed to load stats'); } export async function createInviteLink(id: number): Promise { - const token = localStorage.getItem('ta_token') || ''; - const res = await fetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) throw new Error('Failed to create invite'); - const json = await res.json(); - return json.link as string; + const response = await authorizedFetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST' }); + const data = await jsonOrThrow<{ link: string }>(response, 'Failed to create invite'); + return data.link; } -export async function deletePhoto(id: number) { - const token = localStorage.getItem('ta_token') || ''; - await fetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); +export async function deletePhoto(id: number): Promise { + const response = await authorizedFetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE' }); + if (!response.ok) { + await safeJson(response); + throw new Error('Failed to delete photo'); + } } diff --git a/resources/js/admin/auth/context.tsx b/resources/js/admin/auth/context.tsx new file mode 100644 index 0000000..11446e6 --- /dev/null +++ b/resources/js/admin/auth/context.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { + authorizedFetch, + clearOAuthSession, + clearTokens, + completeOAuthCallback, + isAuthError, + loadTokens, + registerAuthFailureHandler, + startOAuthFlow, +} from './tokens'; + +export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; + +export interface TenantProfile { + id: number; + tenant_id: number; + name?: string; + slug?: string; + email?: string | null; + event_credits_balance?: number; + [key: string]: unknown; +} + +interface AuthContextValue { + status: AuthStatus; + user: TenantProfile | null; + login: (redirectPath?: string) => void; + logout: (options?: { redirect?: string }) => void; + completeLogin: (params: URLSearchParams) => Promise; + refreshProfile: () => Promise; +} + +const AuthContext = React.createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [status, setStatus] = React.useState('loading'); + const [user, setUser] = React.useState(null); + + const handleAuthFailure = React.useCallback(() => { + clearTokens(); + setUser(null); + setStatus('unauthenticated'); + }, []); + + React.useEffect(() => { + const unsubscribe = registerAuthFailureHandler(handleAuthFailure); + return unsubscribe; + }, [handleAuthFailure]); + + const refreshProfile = React.useCallback(async () => { + try { + const response = await authorizedFetch('/api/v1/tenant/me'); + if (!response.ok) { + throw new Error('Failed to load profile'); + } + const profile = (await response.json()) as TenantProfile; + setUser(profile); + setStatus('authenticated'); + } catch (error) { + if (isAuthError(error)) { + handleAuthFailure(); + } else { + console.error('[Auth] Failed to refresh profile', error); + } + throw error; + } + }, [handleAuthFailure]); + + React.useEffect(() => { + const tokens = loadTokens(); + if (!tokens) { + setStatus('unauthenticated'); + return; + } + + refreshProfile().catch(() => { + // refreshProfile already handled failures. + }); + }, [refreshProfile]); + + const login = React.useCallback((redirectPath?: string) => { + const target = redirectPath ?? window.location.pathname + window.location.search; + startOAuthFlow(target); + }, []); + + const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => { + clearTokens(); + clearOAuthSession(); + setUser(null); + setStatus('unauthenticated'); + if (redirect) { + window.location.href = redirect; + } + }, []); + + const completeLogin = React.useCallback( + async (params: URLSearchParams) => { + setStatus('loading'); + try { + const redirectTarget = await completeOAuthCallback(params); + await refreshProfile(); + return redirectTarget; + } catch (error) { + handleAuthFailure(); + throw error; + } + }, + [handleAuthFailure, refreshProfile] + ); + + const value = React.useMemo( + () => ({ status, user, login, logout, completeLogin, refreshProfile }), + [status, user, login, logout, completeLogin, refreshProfile] + ); + + return {children}; +}; + +export function useAuth(): AuthContextValue { + const context = React.useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/resources/js/admin/auth/pkce.ts b/resources/js/admin/auth/pkce.ts new file mode 100644 index 0000000..af6ea37 --- /dev/null +++ b/resources/js/admin/auth/pkce.ts @@ -0,0 +1,16 @@ +import { base64UrlEncode } from './utils'; + +export function generateState(): string { + return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(32))); +} + +export function generateCodeVerifier(): string { + // RFC 7636 recommends a length between 43 and 128 characters. + return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(64))); +} + +export async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier); + const digest = await window.crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(new Uint8Array(digest)); +} diff --git a/resources/js/admin/auth/tokens.ts b/resources/js/admin/auth/tokens.ts new file mode 100644 index 0000000..aa794ca --- /dev/null +++ b/resources/js/admin/auth/tokens.ts @@ -0,0 +1,238 @@ +import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce'; +import { decodeStoredTokens } from './utils'; + +const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1'; +const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier'; +const STATE_KEY = 'tenant_oauth_state'; +const REDIRECT_KEY = 'tenant_oauth_redirect'; +const TOKEN_ENDPOINT = '/api/v1/oauth/token'; +const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize'; +const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write'; + +function getClientId(): string { + const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined; + if (!clientId) { + throw new Error('VITE_OAUTH_CLIENT_ID is not configured'); + } + return clientId; +} + +function buildRedirectUri(): string { + return new URL('/admin/auth/callback', window.location.origin).toString(); +} + +export class AuthError extends Error { + constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) { + super(message ?? code); + this.name = 'AuthError'; + } +} + +export function isAuthError(value: unknown): value is AuthError { + return value instanceof AuthError; +} + +type AuthFailureHandler = () => void; +const authFailureHandlers = new Set(); + +function notifyAuthFailure() { + authFailureHandlers.forEach((handler) => { + try { + handler(); + } catch (error) { + console.error('[Auth] Failure handler threw', error); + } + }); +} + +export function registerAuthFailureHandler(handler: AuthFailureHandler): () => void { + authFailureHandlers.add(handler); + return () => { + authFailureHandlers.delete(handler); + }; +} + +export interface StoredTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope?: string; +} + +export interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + scope?: string; +} + +export function loadTokens(): StoredTokens | null { + const raw = localStorage.getItem(TOKEN_STORAGE_KEY); + const stored = decodeStoredTokens(raw); + if (!stored) { + return null; + } + + if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) { + clearTokens(); + return null; + } + + return stored; +} + +export function saveTokens(response: TokenResponse): StoredTokens { + const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000; + const stored: StoredTokens = { + accessToken: response.access_token, + refreshToken: response.refresh_token, + expiresAt, + scope: response.scope, + }; + localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored)); + return stored; +} + +export function clearTokens(): void { + localStorage.removeItem(TOKEN_STORAGE_KEY); +} + +export async function ensureAccessToken(): Promise { + const tokens = loadTokens(); + if (!tokens) { + notifyAuthFailure(); + throw new AuthError('unauthenticated', 'No tokens available'); + } + + if (Date.now() < tokens.expiresAt) { + return tokens.accessToken; + } + + return refreshAccessToken(tokens.refreshToken); +} + +async function refreshAccessToken(refreshToken: string): Promise { + if (!refreshToken) { + notifyAuthFailure(); + throw new AuthError('unauthenticated', 'Missing refresh token'); + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: getClientId(), + }); + + const response = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }); + + if (!response.ok) { + console.warn('[Auth] Refresh token request failed', response.status); + notifyAuthFailure(); + throw new AuthError('unauthenticated', 'Refresh token invalid'); + } + + const data = (await response.json()) as TokenResponse; + const stored = saveTokens(data); + return stored.accessToken; +} + +export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise { + const token = await ensureAccessToken(); + const headers = new Headers(init.headers); + headers.set('Authorization', `Bearer ${token}`); + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json'); + } + + const response = await fetch(input, { ...init, headers }); + if (response.status === 401) { + notifyAuthFailure(); + throw new AuthError('unauthorized', 'Access token rejected'); + } + + return response; +} + +export async function startOAuthFlow(redirectPath?: string): Promise { + const verifier = generateCodeVerifier(); + const state = generateState(); + const challenge = await generateCodeChallenge(verifier); + + sessionStorage.setItem(CODE_VERIFIER_KEY, verifier); + sessionStorage.setItem(STATE_KEY, state); + if (redirectPath) { + sessionStorage.setItem(REDIRECT_KEY, redirectPath); + } + + const params = new URLSearchParams({ + response_type: 'code', + client_id: getClientId(), + redirect_uri: buildRedirectUri(), + scope: SCOPES, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`; +} + +export async function completeOAuthCallback(params: URLSearchParams): Promise { + if (params.get('error')) { + throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error'); + } + + const code = params.get('code'); + const returnedState = params.get('state'); + const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY); + const expectedState = sessionStorage.getItem(STATE_KEY); + + if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) { + notifyAuthFailure(); + throw new AuthError('invalid_state', 'PKCE state mismatch'); + } + + sessionStorage.removeItem(CODE_VERIFIER_KEY); + sessionStorage.removeItem(STATE_KEY); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: getClientId(), + redirect_uri: buildRedirectUri(), + code_verifier: verifier, + }); + + const response = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + console.error('[Auth] Authorization code exchange failed', response.status); + notifyAuthFailure(); + throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code'); + } + + const data = (await response.json()) as TokenResponse; + saveTokens(data); + + const redirectTarget = sessionStorage.getItem(REDIRECT_KEY); + if (redirectTarget) { + sessionStorage.removeItem(REDIRECT_KEY); + } + + return redirectTarget; +} + +export function clearOAuthSession(): void { + sessionStorage.removeItem(CODE_VERIFIER_KEY); + sessionStorage.removeItem(STATE_KEY); + sessionStorage.removeItem(REDIRECT_KEY); +} diff --git a/resources/js/admin/auth/utils.ts b/resources/js/admin/auth/utils.ts new file mode 100644 index 0000000..23614fa --- /dev/null +++ b/resources/js/admin/auth/utils.ts @@ -0,0 +1,20 @@ +export function base64UrlEncode(buffer: Uint8Array): string { + let binary = ''; + buffer.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export function decodeStoredTokens(value: string | null): T | null { + if (!value) { + return null; + } + + try { + return JSON.parse(value) as T; + } catch (error) { + console.warn('[Auth] Failed to parse stored tokens', error); + return null; + } +} diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx index 3b7df0f..3e760b4 100644 --- a/resources/js/admin/main.tsx +++ b/resources/js/admin/main.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; +import { AuthProvider } from './auth/context'; import { router } from './router'; import '../../css/app.css'; import { initializeTheme } from '@/hooks/use-appearance'; @@ -9,7 +10,8 @@ initializeTheme(); const rootEl = document.getElementById('root')!; createRoot(rootEl).render( - + + + ); - diff --git a/resources/js/admin/pages/AuthCallbackPage.tsx b/resources/js/admin/pages/AuthCallbackPage.tsx new file mode 100644 index 0000000..f32a3f5 --- /dev/null +++ b/resources/js/admin/pages/AuthCallbackPage.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth/context'; +import { isAuthError } from '../auth/tokens'; + +export default function AuthCallbackPage() { + const { completeLogin } = useAuth(); + const navigate = useNavigate(); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + completeLogin(params) + .then((redirectTo) => { + navigate(redirectTo ?? '/admin', { replace: true }); + }) + .catch((err) => { + console.error('[Auth] Callback processing failed', err); + if (isAuthError(err) && err.code === 'token_exchange_failed') { + setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.'); + } else if (isAuthError(err) && err.code === 'invalid_state') { + setError('Ungueltiger Login-Vorgang. Bitte starte die Anmeldung erneut.'); + } else { + setError('Unbekannter Fehler beim Login.'); + } + }); + }, [completeLogin, navigate]); + + return ( +
+ Anmeldung wird verarbeitet ... + {error &&
{error}
} +
+ ); +} diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index aa912a8..ddecc1d 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { getEvent, getEventStats, toggleEvent, createInviteLink } from '../api'; import { Button } from '@/components/ui/button'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { createInviteLink, getEvent, getEventStats, toggleEvent } from '../api'; +import { isAuthError } from '../auth/tokens'; export default function EventDetailPage() { const [sp] = useSearchParams(); @@ -11,35 +12,67 @@ export default function EventDetailPage() { const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null); const [invite, setInvite] = React.useState(null); - async function load() { - const e = await getEvent(id); - setEv(e); - setStats(await getEventStats(id)); - } - React.useEffect(() => { load(); }, [id]); + const load = React.useCallback(async () => { + try { + const event = await getEvent(id); + setEv(event); + setStats(await getEventStats(id)); + } catch (error) { + if (!isAuthError(error)) { + console.error(error); + } + } + }, [id]); + + React.useEffect(() => { + load(); + }, [load]); async function onToggle() { - const isActive = await toggleEvent(id); - setEv((o: any) => ({ ...(o || {}), is_active: isActive })); + try { + const isActive = await toggleEvent(id); + setEv((previous: any) => ({ ...(previous || {}), is_active: isActive })); + } catch (error) { + if (!isAuthError(error)) { + console.error(error); + } + } } async function onInvite() { - const link = await createInviteLink(id); - setInvite(link); - try { await navigator.clipboard.writeText(link); } catch {} + try { + const link = await createInviteLink(id); + setInvite(link); + try { + await navigator.clipboard.writeText(link); + } catch { + // clipboard may be unavailable + } + } catch (error) { + if (!isAuthError(error)) { + console.error(error); + } + } } - if (!ev) return
Lade…
; - const joinLink = `${location.origin}/e/${ev.slug}`; + if (!ev) { + return
Lade ...
; + } + + const joinLink = `${window.location.origin}/e/${ev.slug}`; const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`; return ( -
+

Event: {renderName(ev.name)}

- - + +
@@ -47,31 +80,45 @@ export default function EventDetailPage() {
Datum: {ev.date ?? '-'}
Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}
-
-
{stats?.total ?? 0}
Fotos
-
{stats?.featured ?? 0}
Featured
-
{stats?.likes ?? 0}
Likes gesamt
+
+ + +
-
-
Join-Link
+
+
Join-Link
- +
-
QR
+
QR
QR
- - {invite &&
Erzeugt und kopiert: {invite}
} + + {invite && ( +
Erzeugt und kopiert: {invite}
+ )}
); } +function StatCard({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + function renderName(name: any): string { if (typeof name === 'string') return name; if (name && (name.de || name.en)) return name.de || name.en; return JSON.stringify(name); } - diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index a4e6a39..a01731a 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { createEvent, updateEvent } from '../api'; +import { isAuthError } from '../auth/tokens'; import { useNavigate, useSearchParams } from 'react-router-dom'; export default function EventFormPage() { @@ -13,10 +14,12 @@ export default function EventFormPage() { const [date, setDate] = React.useState(''); const [active, setActive] = React.useState(true); const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(null); const isEdit = !!id; async function save() { setSaving(true); + setError(null); try { if (isEdit) { await updateEvent(Number(id), { name, slug, date, is_active: active }); @@ -24,21 +27,31 @@ export default function EventFormPage() { await createEvent({ name, slug, date, is_active: active }); } nav('/admin/events'); - } finally { setSaving(false); } + } catch (e) { + if (!isAuthError(e)) { + setError('Speichern fehlgeschlagen'); + } + } finally { + setSaving(false); + } } return ( -
+

{isEdit ? 'Event bearbeiten' : 'Neues Event'}

+ {error &&
{error}
} setName(e.target.value)} /> setSlug(e.target.value)} /> setDate(e.target.value)} /> - +
- +
); } - diff --git a/resources/js/admin/pages/EventPhotosPage.tsx b/resources/js/admin/pages/EventPhotosPage.tsx index 6907978..f5abd2f 100644 --- a/resources/js/admin/pages/EventPhotosPage.tsx +++ b/resources/js/admin/pages/EventPhotosPage.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { useSearchParams } from 'react-router-dom'; -import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api'; import { Button } from '@/components/ui/button'; +import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api'; +import { isAuthError } from '../auth/tokens'; export default function EventPhotosPage() { const [sp] = useSearchParams(); @@ -9,33 +10,83 @@ export default function EventPhotosPage() { const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); - async function load() { + const load = React.useCallback(async () => { setLoading(true); - try { setRows(await getEventPhotos(id)); } finally { setLoading(false); } - } - React.useEffect(() => { load(); }, [id]); + try { + setRows(await getEventPhotos(id)); + } catch (error) { + if (!isAuthError(error)) { + console.error(error); + } + } finally { + setLoading(false); + } + }, [id]); - async function onFeature(p: any) { await featurePhoto(p.id); load(); } - async function onUnfeature(p: any) { await unfeaturePhoto(p.id); load(); } - async function onDelete(p: any) { await deletePhoto(p.id); load(); } + React.useEffect(() => { + load(); + }, [load]); + + async function onFeature(photo: any) { + try { + await featurePhoto(photo.id); + await load(); + } catch (error) { + if (!isAuthError(error)) { + console.error(error); + } + } + } + + async function onUnfeature(photo: any) { + try { + await unfeaturePhoto(photo.id); + await load(); + } catch (error) { + if (!isAuthError(error)) { + console.error(error); + } + } + } + + async function onDelete(photo: any) { + try { + await deletePhoto(photo.id); + await load(); + } catch (error) { + if (!isAuthError(error)) { + console.error(error); + } + } + } return (

Fotos moderieren

- {loading &&
Lade…
} + {loading &&
Lade ...
}
{rows.map((p) => (
- + {p.caption
- ❤ {p.likes_count} + ?? {p.likes_count}
{p.is_featured ? ( - + ) : ( - + )} - +
@@ -44,4 +95,3 @@ export default function EventPhotosPage() {
); } - diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx index 748e3d6..2330fbd 100644 --- a/resources/js/admin/pages/EventsPage.tsx +++ b/resources/js/admin/pages/EventsPage.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { getEvents } from '../api'; import { Button } from '@/components/ui/button'; import { Link, useNavigate } from 'react-router-dom'; +import { getEvents } from '../api'; +import { isAuthError } from '../auth/tokens'; export default function EventsPage() { const [rows, setRows] = React.useState([]); @@ -11,7 +12,15 @@ export default function EventsPage() { React.useEffect(() => { (async () => { - try { setRows(await getEvents()); } catch (e) { setError('Laden fehlgeschlagen'); } finally { setLoading(false); } + try { + setRows(await getEvents()); + } catch (err) { + if (!isAuthError(err)) { + setError('Laden fehlgeschlagen'); + } + } finally { + setLoading(false); + } })(); }, []); @@ -20,24 +29,32 @@ export default function EventsPage() {

Meine Events

- - + + + +
- {loading &&
Lade…
} - {error &&
{error}
} + {loading &&
Lade ...
} + {error && ( +
{error}
+ )}
- {rows.map((e) => ( -
+ {rows.map((event) => ( +
-
{renderName(e.name)}
-
Slug: {e.slug} · Datum: {e.date ?? '-'}
+
{renderName(event.name)}
+
Slug: {event.slug} � Datum: {event.date ?? '-'}
-
- details - bearbeiten - fotos - öffnen +
+ details + bearbeiten + fotos + + �ffnen +
))} diff --git a/resources/js/admin/pages/LoginPage.tsx b/resources/js/admin/pages/LoginPage.tsx index afd37c7..c37c133 100644 --- a/resources/js/admin/pages/LoginPage.tsx +++ b/resources/js/admin/pages/LoginPage.tsx @@ -1,43 +1,61 @@ import React from 'react'; -import { login } from '../api'; -import { useNavigate } from 'react-router-dom'; -import { Input } from '@/components/ui/input'; +import { Location, useLocation, useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; +import { useAuth } from '../auth/context'; + +interface LocationState { + from?: Location; +} export default function LoginPage() { - const nav = useNavigate(); - const [email, setEmail] = React.useState(''); - const [password, setPassword] = React.useState(''); - const [error, setError] = React.useState(null); - const [loading, setLoading] = React.useState(false); + const { status, login } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]); + const oauthError = searchParams.get('error'); - async function submit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setLoading(true); - try { - const { token } = await login(email, password); - localStorage.setItem('ta_token', token); - nav('/admin', { replace: true }); - } catch (err: any) { - setError('Login fehlgeschlagen'); - } finally { setLoading(false); } - } + React.useEffect(() => { + if (status === 'authenticated') { + navigate('/admin', { replace: true }); + } + }, [status, navigate]); + + const redirectTarget = React.useMemo(() => { + 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}`; + } + return '/admin'; + }, [location.state]); return ( -
-
+
+

Tenant Admin

-
- {error &&
{error}
} - setEmail(e.target.value)} /> - setPassword(e.target.value)} /> - -
+
+

+ Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach + wieder zur Admin-Oberflche gebracht. +

+ {oauthError && ( +
+ Anmeldung fehlgeschlagen: {oauthError} +
+ )} + +
); } - diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx index 39318fa..c9bee06 100644 --- a/resources/js/admin/pages/SettingsPage.tsx +++ b/resources/js/admin/pages/SettingsPage.tsx @@ -2,22 +2,34 @@ import React from 'react'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import { Button } from '@/components/ui/button'; import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth/context'; export default function SettingsPage() { const nav = useNavigate(); - function logout() { - localStorage.removeItem('ta_token'); - nav('/admin/login', { replace: true }); + const { user, logout } = useAuth(); + + function handleLogout() { + logout({ redirect: '/admin/login' }); } + return ( -
-

Einstellungen

-
+
+
+

Einstellungen

+ {user && ( +

+ Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id} +

+ )} +
+
Darstellung
- +
+ + +
); } - diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 9a8f55e..318b5cc 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -1,20 +1,36 @@ import React from 'react'; -import { createBrowserRouter, Outlet, Navigate } from 'react-router-dom'; +import { createBrowserRouter, Outlet, Navigate, useLocation } from 'react-router-dom'; import LoginPage from './pages/LoginPage'; import EventsPage from './pages/EventsPage'; import SettingsPage from './pages/SettingsPage'; import EventFormPage from './pages/EventFormPage'; import EventPhotosPage from './pages/EventPhotosPage'; import EventDetailPage from './pages/EventDetailPage'; +import AuthCallbackPage from './pages/AuthCallbackPage'; +import { useAuth } from './auth/context'; function RequireAuth() { - const token = localStorage.getItem('ta_token'); - if (!token) return ; + const { status } = useAuth(); + const location = useLocation(); + + if (status === 'loading') { + return ( +
+ Bitte warten +
+ ); + } + + if (status === 'unauthenticated') { + return ; + } + return ; } export const router = createBrowserRouter([ { path: '/admin/login', element: }, + { path: '/admin/auth/callback', element: }, { path: '/admin', element: , diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index c2e1784..cc0bf81 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -1,18 +1,25 @@ -import React from 'react'; -import { Button } from '@/components/ui/button'; -import { Link } from 'react-router-dom'; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import React from 'react'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; -import { Settings, ChevronDown, User } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { User } from 'lucide-react'; import { useEventData } from '../hooks/useEventData'; -import { usePollStats } from '../polling/usePollStats'; +import { useOptionalEventStats } from '../context/EventStatsContext'; +import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; +import { SettingsSheet } from './settings-sheet'; export default function Header({ slug, title = '' }: { slug?: string; title?: string }) { + const statsContext = useOptionalEventStats(); + const identity = useOptionalGuestIdentity(); + if (!slug) { + const guestName = identity?.name && identity?.hydrated ? identity.name : null; return (
-
{title}
+
+
{title}
+ {guestName && ( + Hi {guestName} + )} +
@@ -22,7 +29,8 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st } const { event, loading: eventLoading, error: eventError } = useEventData(); - const stats = usePollStats(slug); + const stats = statsContext && statsContext.slug === slug ? statsContext : undefined; + const guestName = identity && identity.slug === slug && identity.hydrated && identity.name ? identity.name : null; if (eventLoading) { return ( @@ -48,7 +56,6 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st ); } - // Get event icon or generate initials const getEventAvatar = (event: any) => { if (event.type?.icon) { return ( @@ -57,8 +64,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
); } - - // Fallback to initials + const getInitials = (name: string) => { const words = name.split(' '); if (words.length >= 2) { @@ -80,6 +86,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st {getEventAvatar(event)}
{event.name}
+ {guestName && ( + Hi {guestName} + )}
{stats && ( <> @@ -87,9 +96,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st {stats.onlineGuests} online - + | - {stats.tasksSolved} Aufgaben gelöst + {stats.tasksSolved} Aufgaben geloest )} @@ -104,88 +113,4 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st ); } -function SettingsSheet() { - return ( - - - - - - - Einstellungen - -
- -
-
Cache
- - - -
- -
- -
-
-
- - -
-
Rechtliches
- - - -
- -
    -
  • Impressum
  • -
  • Datenschutz
  • -
  • AGB
  • -
-
-
-
-
-
- ); -} - -function ClearCacheButton() { - const [busy, setBusy] = React.useState(false); - const [done, setDone] = React.useState(false); - - async function clearAll() { - setBusy(true); setDone(false); - try { - // Clear CacheStorage - if ('caches' in window) { - const keys = await caches.keys(); - await Promise.all(keys.map((k) => caches.delete(k))); - } - // Clear known IndexedDB dbs (best-effort) - if ('indexedDB' in window) { - try { await new Promise((res, rej) => { const r = indexedDB.deleteDatabase('upload-queue'); r.onsuccess=()=>res(null); r.onerror=()=>res(null); }); } catch {} - } - setDone(true); - } finally { - setBusy(false); - setTimeout(() => setDone(false), 2500); - } - } - - return ( -
- - {done &&
Cache gelöscht.
} -
- ); -} +export {} diff --git a/resources/js/guest/components/legal-markdown.tsx b/resources/js/guest/components/legal-markdown.tsx new file mode 100644 index 0000000..0b9df4d --- /dev/null +++ b/resources/js/guest/components/legal-markdown.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +type Props = { + markdown: string; +}; + +export function LegalMarkdown({ markdown }: Props) { + const html = React.useMemo(() => { + let safe = markdown + .replace(/&/g, '&') + .replace(//g, '>'); + safe = safe.replace(/\*\*(.+?)\*\*/g, '$1'); + safe = safe.replace(/(?$1'); + safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '$1'); + safe = safe + .split(/\n{2,}/) + .map((block) => `

${block.replace(/\n/g, '
')}

`) + .join('\n'); + return safe; + }, [markdown]); + + return
; +} \ No newline at end of file diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx new file mode 100644 index 0000000..46b672c --- /dev/null +++ b/resources/js/guest/components/settings-sheet.tsx @@ -0,0 +1,401 @@ +import React from "react"; +import { Button } from '@/components/ui/button'; +import { + Sheet, + SheetTrigger, + SheetContent, + SheetTitle, + SheetDescription, + SheetFooter, +} from '@/components/ui/sheet'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react'; +import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; +import { LegalMarkdown } from './legal-markdown'; + +const legalPages = [ + { slug: 'impressum', label: 'Impressum' }, + { slug: 'datenschutz', label: 'Datenschutz' }, + { slug: 'agb', label: 'AGB' }, +] as const; + +type ViewState = + | { mode: 'home' } + | { mode: 'legal'; slug: (typeof legalPages)[number]['slug']; label: string }; + +type LegalDocumentState = + | { phase: 'idle'; title: string; body: string } + | { phase: 'loading'; title: string; body: string } + | { phase: 'ready'; title: string; body: string } + | { phase: 'error'; title: string; body: string }; + +type NameStatus = 'idle' | 'saved'; + +export function SettingsSheet() { + const [open, setOpen] = React.useState(false); + const [view, setView] = React.useState({ mode: 'home' }); + const identity = useOptionalGuestIdentity(); + const [nameDraft, setNameDraft] = React.useState(identity?.name ?? ''); + const [nameStatus, setNameStatus] = React.useState('idle'); + const [savingName, setSavingName] = React.useState(false); + const isLegal = view.mode === 'legal'; + const legalDocument = useLegalDocument(isLegal ? view.slug : null); + + React.useEffect(() => { + if (open && identity?.hydrated) { + setNameDraft(identity.name ?? ''); + setNameStatus('idle'); + } + }, [open, identity?.hydrated, identity?.name]); + + const handleBack = React.useCallback(() => { + setView({ mode: 'home' }); + }, []); + + const handleOpenLegal = React.useCallback( + (slug: (typeof legalPages)[number]['slug'], label: string) => { + setView({ mode: 'legal', slug, label }); + }, + [] + ); + + const handleOpenChange = React.useCallback((next: boolean) => { + setOpen(next); + if (!next) { + setView({ mode: 'home' }); + setNameStatus('idle'); + } + }, []); + + const canSaveName = Boolean( + identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '') + ); + + const handleSaveName = React.useCallback(() => { + if (!identity || !canSaveName) { + return; + } + setSavingName(true); + try { + identity.setName(nameDraft); + setNameStatus('saved'); + window.setTimeout(() => setNameStatus('idle'), 2000); + } finally { + setSavingName(false); + } + }, [identity, nameDraft, canSaveName]); + + const handleResetName = React.useCallback(() => { + if (!identity) return; + identity.clearName(); + setNameDraft(''); + setNameStatus('idle'); + }, [identity]); + + return ( + + + + + +
+
+ {isLegal ? ( +
+ +
+ + {legalDocument.phase === 'ready' && legalDocument.title + ? legalDocument.title + : view.label} + + + {legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'} + +
+
+ ) : ( +
+ Einstellungen + + Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten. + +
+ )} +
+ +
+ {isLegal ? ( + handleOpenChange(false)} /> + ) : ( + + )} +
+ + +
Gastbereich - Daten werden lokal im Browser gespeichert.
+
+
+
+
+ ); +} + +function LegalView({ document, onClose }: { document: LegalDocumentState; onClose: () => void }) { + if (document.phase === 'error') { + return ( +
+ + + Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut. + + + +
+ ); + } + + if (document.phase === 'loading' || document.phase === 'idle') { + return
Dokument wird geladen...
; + } + + return ( +
+ + + {document.title || 'Rechtlicher Hinweis'} + + + + + +
+ ); +} + +interface HomeViewProps { + identity: ReturnType; + nameDraft: string; + onNameChange: (value: string) => void; + onSaveName: () => void; + onResetName: () => void; + canSaveName: boolean; + savingName: boolean; + nameStatus: NameStatus; + onOpenLegal: (slug: (typeof legalPages)[number]['slug'], label: string) => void; +} + +function HomeView({ + identity, + nameDraft, + onNameChange, + onSaveName, + onResetName, + canSaveName, + savingName, + nameStatus, + onOpenLegal, +}: HomeViewProps) { + return ( +
+ {identity && ( + + + Dein Name + + Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert. + + + +
+
+ +
+
+ + onNameChange(event.target.value)} + autoComplete="name" + disabled={!identity.hydrated || savingName} + /> +
+
+
+ + + {nameStatus === 'saved' && ( + Gespeichert (ok) + )} + {!identity.hydrated && ( + Lade gespeicherten Namen... + )} +
+
+
+ )} + + + + +
+ + Rechtliches +
+
+ + Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar. + +
+ + {legalPages.map((page) => ( + + ))} + +
+ + + + Offline Cache + + Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben. + + + + +
+ + Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden. +
+
+
+
+ ); +} + +function useLegalDocument(slug: string | null): LegalDocumentState { + const [state, setState] = React.useState({ + phase: 'idle', + title: '', + body: '', + }); + + React.useEffect(() => { + if (!slug) { + setState({ phase: 'idle', title: '', body: '' }); + return; + } + + const controller = new AbortController(); + setState({ phase: 'loading', title: '', body: '' }); + + fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, { + headers: { 'Cache-Control': 'no-store' }, + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) { + throw new Error('failed'); + } + const payload = await res.json(); + setState({ + phase: 'ready', + title: payload.title ?? '', + body: payload.body_markdown ?? '', + }); + }) + .catch((error) => { + if (controller.signal.aborted) { + return; + } + console.error('Failed to load legal page', error); + setState({ phase: 'error', title: '', body: '' }); + }); + + return () => controller.abort(); + }, [slug]); + + return state; +} + +function ClearCacheButton() { + const [busy, setBusy] = React.useState(false); + const [done, setDone] = React.useState(false); + + async function clearAll() { + setBusy(true); + setDone(false); + try { + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + } + if ('indexedDB' in window) { + try { + await new Promise((resolve) => { + const request = indexedDB.deleteDatabase('upload-queue'); + request.onsuccess = () => resolve(null); + request.onerror = () => resolve(null); + }); + } catch (error) { + console.warn('IndexedDB cleanup failed', error); + } + } + setDone(true); + } finally { + setBusy(false); + window.setTimeout(() => setDone(false), 2500); + } + } + + return ( +
+ + {done &&
Cache geloescht.
} +
+ ); +} diff --git a/resources/js/guest/context/EventStatsContext.tsx b/resources/js/guest/context/EventStatsContext.tsx new file mode 100644 index 0000000..dd76d06 --- /dev/null +++ b/resources/js/guest/context/EventStatsContext.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { usePollStats } from '../polling/usePollStats'; + +type EventStatsContextValue = ReturnType & { + slug: string; +}; + +const EventStatsContext = React.createContext(undefined); + +export function EventStatsProvider({ slug, children }: { slug: string; children: React.ReactNode }) { + const stats = usePollStats(slug); + const value = React.useMemo( + () => ({ slug, ...stats }), + [slug, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading] + ); + return {children}; +} + +export function useEventStats() { + const ctx = React.useContext(EventStatsContext); + if (!ctx) { + throw new Error('useEventStats must be used within an EventStatsProvider'); + } + return ctx; +} + +export function useOptionalEventStats() { + return React.useContext(EventStatsContext); +} diff --git a/resources/js/guest/context/GuestIdentityContext.tsx b/resources/js/guest/context/GuestIdentityContext.tsx new file mode 100644 index 0000000..e4c301e --- /dev/null +++ b/resources/js/guest/context/GuestIdentityContext.tsx @@ -0,0 +1,109 @@ +import React from 'react'; + +type GuestIdentityContextValue = { + slug: string; + name: string; + hydrated: boolean; + setName: (nextName: string) => void; + clearName: () => void; + reload: () => void; +}; + +const GuestIdentityContext = React.createContext(undefined); + +function storageKey(slug: string) { + return `guestName_${slug}`; +} + +export function readGuestName(slug: string) { + if (!slug || typeof window === 'undefined') { + return ''; + } + + try { + return window.localStorage.getItem(storageKey(slug)) ?? ''; + } catch (error) { + console.warn('Failed to read guest name', error); + return ''; + } +} + +export function GuestIdentityProvider({ slug, children }: { slug: string; children: React.ReactNode }) { + const [name, setNameState] = React.useState(''); + const [hydrated, setHydrated] = React.useState(false); + + const loadFromStorage = React.useCallback(() => { + if (!slug) { + setHydrated(true); + setNameState(''); + return; + } + + try { + const stored = window.localStorage.getItem(storageKey(slug)); + setNameState(stored ?? ''); + } catch (error) { + console.warn('Failed to read guest name from storage', error); + setNameState(''); + } finally { + setHydrated(true); + } + }, [slug]); + + React.useEffect(() => { + setHydrated(false); + loadFromStorage(); + }, [loadFromStorage]); + + const persistName = React.useCallback( + (nextName: string) => { + const trimmed = nextName.trim(); + setNameState(trimmed); + try { + if (trimmed) { + window.localStorage.setItem(storageKey(slug), trimmed); + } else { + window.localStorage.removeItem(storageKey(slug)); + } + } catch (error) { + console.warn('Failed to persist guest name', error); + } + }, + [slug] + ); + + const clearName = React.useCallback(() => { + setNameState(''); + try { + window.localStorage.removeItem(storageKey(slug)); + } catch (error) { + console.warn('Failed to clear guest name', error); + } + }, [slug]); + + const value = React.useMemo( + () => ({ + slug, + name, + hydrated, + setName: persistName, + clearName, + reload: loadFromStorage, + }), + [slug, name, hydrated, persistName, clearName, loadFromStorage] + ); + + return {children}; +} + +export function useGuestIdentity() { + const ctx = React.useContext(GuestIdentityContext); + if (!ctx) { + throw new Error('useGuestIdentity must be used within a GuestIdentityProvider'); + } + return ctx; +} + +export function useOptionalGuestIdentity() { + return React.useContext(GuestIdentityContext); +} diff --git a/resources/js/guest/hooks/useGuestTaskProgress.ts b/resources/js/guest/hooks/useGuestTaskProgress.ts new file mode 100644 index 0000000..02a1782 --- /dev/null +++ b/resources/js/guest/hooks/useGuestTaskProgress.ts @@ -0,0 +1,104 @@ +import React from 'react'; + +function storageKey(slug: string) { + return `guestTasks_${slug}`; +} + +function parseStored(value: string | null) { + if (!value) { + return [] as number[]; + } + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.filter((item) => Number.isInteger(item)) as number[]; + } + return []; + } catch (error) { + console.warn('Failed to parse task progress from storage', error); + return []; + } +} + +export function useGuestTaskProgress(slug: string | undefined) { + const [completed, setCompleted] = React.useState([]); + const [hydrated, setHydrated] = React.useState(false); + + React.useEffect(() => { + if (!slug) { + setCompleted([]); + setHydrated(true); + return; + } + try { + const stored = window.localStorage.getItem(storageKey(slug)); + setCompleted(parseStored(stored)); + } catch (error) { + console.warn('Failed to read task progress', error); + setCompleted([]); + } finally { + setHydrated(true); + } + }, [slug]); + + const persist = React.useCallback( + (next: number[]) => { + if (!slug) return; + setCompleted(next); + try { + window.localStorage.setItem(storageKey(slug), JSON.stringify(next)); + } catch (error) { + console.warn('Failed to persist task progress', error); + } + }, + [slug] + ); + + const markCompleted = React.useCallback( + (taskId: number) => { + if (!slug || !Number.isInteger(taskId)) { + return; + } + setCompleted((prev) => { + if (prev.includes(taskId)) { + return prev; + } + const next = [...prev, taskId]; + try { + window.localStorage.setItem(storageKey(slug), JSON.stringify(next)); + } catch (error) { + console.warn('Failed to persist task progress', error); + } + return next; + }); + }, + [slug] + ); + + const clearProgress = React.useCallback(() => { + if (!slug) return; + setCompleted([]); + try { + window.localStorage.removeItem(storageKey(slug)); + } catch (error) { + console.warn('Failed to clear task progress', error); + } + }, [slug]); + + const isCompleted = React.useCallback( + (taskId: number | null | undefined) => { + if (!Number.isInteger(taskId)) return false; + return completed.includes(taskId as number); + }, + [completed] + ); + + return { + hydrated, + completed, + completedCount: completed.length, + markCompleted, + clearProgress, + isCompleted, + }; +} diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx index d781d59..700e9c0 100644 --- a/resources/js/guest/pages/AchievementsPage.tsx +++ b/resources/js/guest/pages/AchievementsPage.tsx @@ -1,11 +1,444 @@ -import React from 'react'; -import { Page } from './_util'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Separator } from '@/components/ui/separator'; +import { + AchievementBadge, + AchievementsPayload, + FeedEntry, + LeaderboardEntry, + TimelinePoint, + TopPhotoHighlight, + TrendingEmotionHighlight, + fetchAchievements, +} from '../services/achievementApi'; +import { useGuestIdentity } from '../context/GuestIdentityContext'; +import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react'; -export default function AchievementsPage() { +function formatNumber(value: number): string { + return new Intl.NumberFormat('de-DE').format(value); +} + +function formatRelativeTime(input: string): string { + const date = new Date(input); + if (Number.isNaN(date.getTime())) return ''; + const diff = Date.now() - date.getTime(); + const minute = 60_000; + const hour = 60 * minute; + const day = 24 * hour; + if (diff < minute) return 'gerade eben'; + if (diff < hour) { + const minutes = Math.round(diff / minute); + return `vor ${minutes} Min`; + } + if (diff < day) { + const hours = Math.round(diff / hour); + return `vor ${hours} Std`; + } + const days = Math.round(diff / day); + return `vor ${days} Tagen`; +} + +function badgeVariant(earned: boolean): string { + return earned ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/30' : 'bg-muted text-muted-foreground'; +} + +function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) { return ( - -

Badges and progress placeholder.

-
+ + +
+ +
+
+ {title} + Top 5 Teilnehmer dieses Events +
+
+ + {entries.length === 0 ? ( +

{emptyCopy}

+ ) : ( +
    + {entries.map((entry, index) => ( +
  1. +
    + #{index + 1} + {entry.guest || 'Gast'} +
    +
    + {entry.photos} Fotos + {entry.likes} Likes +
    +
  2. + ))} +
+ )} +
+
); } +function BadgesGrid({ badges }: { badges: AchievementBadge[] }) { + if (badges.length === 0) { + return ( + + + Badges + Erfuelle Aufgaben und sammle Likes, um Badges freizuschalten. + + +

Noch keine Badges verfuegbar.

+
+
+ ); + } + + return ( + + + Badges + Dein Fortschritt bei den verfuegbaren Erfolgen. + + + {badges.map((badge) => ( +
+
+
+

{badge.title}

+

{badge.description}

+
+ +
+
+ {badge.earned ? 'Abgeschlossen' : `Fortschritt: ${badge.progress}/${badge.target}`} +
+
+ ))} +
+
+ ); +} + +function Timeline({ points }: { points: TimelinePoint[] }) { + if (points.length === 0) { + return null; + } + return ( + + + Timeline + Wie das Event im Laufe der Zeit Fahrt aufgenommen hat. + + + {points.map((point) => ( +
+ {point.date} + {point.photos} Fotos | {point.guests} Gaeste +
+ ))} +
+
+ ); +} + +function Feed({ feed }: { feed: FeedEntry[] }) { + if (feed.length === 0) { + return ( + + + Live Feed + Neue Uploads erscheinen hier in Echtzeit. + + +

Noch keine Uploads - starte die Kamera und lege los!

+
+
+ ); + } + + return ( + + + Live Feed + Die neuesten Momente aus deinem Event. + + + {feed.map((item) => ( +
+ {item.thumbnail ? ( + Vorschau + ) : ( +
+ )} +
+

{item.guest || 'Gast'}

+ {item.task &&

Aufgabe: {item.task}

} +
+ {formatRelativeTime(item.createdAt)} + {item.likes} Likes +
+
+
+ ))} +
+
+ ); +} + +function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null }) { + if (!topPhoto && !trendingEmotion) { + return null; + } + + return ( +
+ {topPhoto && ( + + +
+ Publikumsliebling + Das Foto mit den meisten Likes. +
+ +
+ +
+ {topPhoto.thumbnail ? ( + Top Foto + ) : ( +
Kein Vorschau-Bild
+ )} +
+

{topPhoto.guest || 'Gast'} � {topPhoto.likes} Likes

+ {topPhoto.task &&

Aufgabe: {topPhoto.task}

} +

{formatRelativeTime(topPhoto.createdAt)}

+
+
+ )} + + {trendingEmotion && ( + + +
+ Trend-Emotion + Diese Stimmung taucht gerade besonders oft auf. +
+ +
+ +

{trendingEmotion.name}

+

{trendingEmotion.count} Fotos mit dieser Stimmung

+
+
+ )} +
+ ); +} + +function SummaryCards({ data }: { data: AchievementsPayload }) { + return ( +
+ + + Fotos gesamt + {formatNumber(data.summary.totalPhotos)} + + + + + Aktive Gaeste + {formatNumber(data.summary.uniqueGuests)} + + + + + Erfuellte Aufgaben + {formatNumber(data.summary.tasksSolved)} + + + + + Likes insgesamt + {formatNumber(data.summary.likesTotal)} + + +
+ ); +} + +function PersonalActions({ slug }: { slug: string }) { + return ( +
+ + +
+ ); +} + +export default function AchievementsPage() { + const { slug } = useParams<{ slug: string }>(); + const identity = useGuestIdentity(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<'personal' | 'event' | 'feed'>('personal'); + + const personalName = identity.hydrated && identity.name ? identity.name : undefined; + + useEffect(() => { + if (!slug) return; + const controller = new AbortController(); + setLoading(true); + setError(null); + + fetchAchievements(slug, personalName, controller.signal) + .then((payload) => { + setData(payload); + if (!payload.personal) { + setActiveTab('event'); + } + }) + .catch((err) => { + if (err.name === 'AbortError') return; + console.error('Failed to load achievements', err); + setError(err.message || 'Erfolge konnten nicht geladen werden.'); + }) + .finally(() => setLoading(false)); + + return () => controller.abort(); + }, [slug, personalName]); + + const hasPersonal = Boolean(data?.personal); + + if (!slug) { + return null; + } + + return ( +
+
+
+
+ +
+
+

Erfolge

+

Behalte deine Highlights, Badges und die aktivsten Gaeste im Blick.

+
+
+
+ + {loading && ( +
+ + + +
+ )} + + {!loading && error && ( + + + {error} + + + + )} + + {!loading && !error && data && ( + <> + + +
+ + + +
+ + + + {activeTab === 'personal' && hasPersonal && data.personal && ( +
+ + +
+ Hi {data.personal.guestName || identity.name || 'Gast'}! + + {data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes + +
+ +
+
+ + +
+ )} + + {activeTab === 'event' && ( +
+ + +
+ + +
+
+ )} + + {activeTab === 'feed' && } + + )} +
+ ); +} diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 87a0ae9..cf3bda8 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -106,17 +106,7 @@ export default function GalleryPage() { imageUrl = imageUrl.replace(/\/+/g, '/'); } - // Extended debug logging - console.log(`Photo ${p.id} URL processing:`, { - id: p.id, - original: imgSrc, - thumbnail_path: p.thumbnail_path, - file_path: p.file_path, - cleanPath, - finalUrl: imageUrl, - isHttp: imageUrl?.startsWith('http'), - startsWithStorage: imageUrl?.startsWith('/storage/') - }); + // Production: avoid heavy console logging for each image return ( @@ -133,11 +123,8 @@ export default function GalleryPage() { alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`} className="aspect-square w-full object-cover bg-gray-200" onError={(e) => { - console.error(`❌ Failed to load image ${p.id}:`, imageUrl); - console.error('Error details:', e); (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg=='; }} - onLoad={() => console.log(`✅ Successfully loaded image ${p.id}:`, imageUrl)} loading="lazy" />
diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index 088168e..0cdd6ed 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -1,37 +1,186 @@ -import React from 'react'; -import { Page } from './_util'; -import { useParams, Link } from 'react-router-dom'; -import { usePollStats } from '../polling/usePollStats'; +import React from 'react'; +import { Link, useParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import Header from '../components/Header'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; import EmotionPicker from '../components/EmotionPicker'; import GalleryPreview from '../components/GalleryPreview'; -import BottomNav from '../components/BottomNav'; +import { useGuestIdentity } from '../context/GuestIdentityContext'; +import { useEventStats } from '../context/EventStatsContext'; +import { useEventData } from '../hooks/useEventData'; +import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; +import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react'; export default function HomePage() { - const { slug } = useParams(); - const stats = usePollStats(slug!); + const { slug } = useParams<{ slug: string }>(); + const { name, hydrated } = useGuestIdentity(); + const stats = useEventStats(); + const { event } = useEventData(); + const { completedCount } = useGuestTaskProgress(slug); + + if (!slug) return null; + + const displayName = hydrated && name ? name : 'Gast'; + const latestUploadText = formatLatestUpload(stats.latestPhotoAt); + + const primaryActions: Array<{ to: string; label: string; description: string; icon: React.ReactNode }> = [ + { + to: 'tasks', + label: 'Aufgabe ziehen', + description: 'Hol dir deine naechste Challenge', + icon: , + }, + { + to: 'upload', + label: 'Direkt hochladen', + description: 'Teile deine neuesten Fotos', + icon: , + }, + { + to: 'gallery', + label: 'Galerie ansehen', + description: 'Lass dich von anderen inspirieren', + icon: , + }, + ]; + + const checklistItems = [ + 'Aufgabe auswaehlen oder starten', + 'Emotion festhalten und Foto schiessen', + 'Bild hochladen und Credits sammeln', + ]; + return ( - -
-
{/* Consistent spacing */} - {/* Prominent Draw Task Button */} - - - +
+ - {/* How do you feel? Section */} - + + + } + label="Gleichzeitig online" + value={`${stats.onlineGuests}`} + /> + } + label="Aufgaben gelöst" + value={`${stats.tasksSolved}`} + /> + } + label="Letzter Upload" + value={latestUploadText} + /> + } + label="Deine erledigten Aufgaben" + value={`${completedCount}`} + /> + + - -
- - {/* Bottom Navigation */} - - +
+
+

Deine Aktionen

+ Waehle aus, womit du starten willst +
+
+ {primaryActions.map((action) => ( + + + +
+ {action.icon} +
+
+ {action.label} + {action.description} +
+
+
+ + ))} +
+ +
+ + + + Dein Fortschritt + Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse. + + + {checklistItems.map((item) => ( +
+ + {item} +
+ ))} +
+
+ + + + + + +
); } + +function HeroCard({ name, eventName, tasksCompleted }: { name: string; eventName: string; tasksCompleted: number }) { + const progressMessage = tasksCompleted > 0 + ? `Schon ${tasksCompleted} Aufgaben erledigt - weiter so!` + : 'Starte mit deiner ersten Aufgabe - wir zählen auf dich!'; + + return ( + + + Willkommen zur Party + Hey {name}! +

Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.

+

{progressMessage}

+
+
+ ); +} + +function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( +
+
+ {icon} +
+
+ {label} + {value} +
+
+ ); +} + +function formatLatestUpload(isoDate: string | null) { + if (!isoDate) { + return 'Noch kein Upload'; + } + const date = new Date(isoDate); + if (Number.isNaN(date.getTime())) { + return 'Noch kein Upload'; + } + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.round(diffMs / 60000); + if (diffMinutes < 1) { + return 'Gerade eben'; + } + if (diffMinutes < 60) { + return `vor ${diffMinutes} Min`; + } + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) { + return `vor ${diffHours} Std`; + } + const diffDays = Math.round(diffHours / 24); + return `vor ${diffDays} Tagen`; +} diff --git a/resources/js/guest/pages/LandingPage.tsx b/resources/js/guest/pages/LandingPage.tsx index 4a42ac8..0e74207 100644 --- a/resources/js/guest/pages/LandingPage.tsx +++ b/resources/js/guest/pages/LandingPage.tsx @@ -1,18 +1,23 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Page } from './_util'; import { useNavigate } from 'react-router-dom'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Html5Qrcode } from 'html5-qrcode'; +import { readGuestName } from '../context/GuestIdentityContext'; export default function LandingPage() { const nav = useNavigate(); - const [slug, setSlug] = React.useState(''); - const [loading, setLoading] = React.useState(false); - const [error, setError] = React.useState(null); + const [slug, setSlug] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isScanning, setIsScanning] = useState(false); + const [scanner, setScanner] = useState(null); - async function join() { - const s = slug.trim(); + async function join(eventSlug?: string) { + const s = (eventSlug ?? slug).trim(); if (!s) return; setLoading(true); setError(null); @@ -22,30 +27,131 @@ export default function LandingPage() { setError('Event nicht gefunden oder geschlossen.'); return; } - nav(`/e/${encodeURIComponent(s)}`); + const storedName = readGuestName(s); + if (!storedName) { + nav(`/setup/${encodeURIComponent(s)}`); + } else { + nav(`/e/${encodeURIComponent(s)}`); + } } catch (e) { - setError('Netzwerkfehler. Bitte später erneut versuchen.'); + console.error('Join request failed', e); + setError('Netzwerkfehler. Bitte spaeter erneut versuchen.'); } finally { setLoading(false); } } + const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const; + + async function startScanner() { + if (scanner) { + try { + await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined); + setIsScanning(true); + } catch (err) { + console.error('Scanner start failed', err); + setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.'); + } + return; + } + + try { + const newScanner = new Html5Qrcode('qr-reader'); + setScanner(newScanner); + await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined); + setIsScanning(true); + } catch (err) { + console.error('Scanner initialisation failed', err); + setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.'); + } + } + + function stopScanner() { + if (!scanner) { + setIsScanning(false); + return; + } + scanner + .stop() + .then(() => { + setIsScanning(false); + }) + .catch((err) => console.error('Scanner stop failed', err)); + } + + async function onScanSuccess(decodedText: string) { + const value = decodedText.trim(); + if (!value) return; + await join(value); + stopScanner(); + } + + useEffect(() => () => { + if (scanner) { + scanner.stop().catch(() => undefined); + } + }, [scanner]); + return ( - + {error && ( {error} )} - setSlug(e.target.value)} - placeholder="QR/PIN oder Event-Slug eingeben" - /> -
- +
+
+

Willkommen bei der Fotobox!

+

Dein Schluessel zu unvergesslichen Momenten.

+
+ + + + Event beitreten + Scanne den QR-Code oder gib den Code manuell ein. + + +
+
+ + + +
+ + +
+ Oder manuell eingeben +
+ +
+ setSlug(event.target.value)} + placeholder="Event-Code eingeben" + disabled={loading} + /> + +
+ + +
); } diff --git a/resources/js/guest/pages/LegalPage.tsx b/resources/js/guest/pages/LegalPage.tsx index 275f828..0a7c4aa 100644 --- a/resources/js/guest/pages/LegalPage.tsx +++ b/resources/js/guest/pages/LegalPage.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React from "react"; import { Page } from './_util'; import { useParams } from 'react-router-dom'; +import { LegalMarkdown } from '../components/legal-markdown'; export default function LegalPage() { const { page } = useParams(); @@ -9,42 +10,44 @@ export default function LegalPage() { const [body, setBody] = React.useState(''); React.useEffect(() => { - async function load() { - setLoading(true); - const res = await fetch(`/api/v1/legal/${encodeURIComponent(page || '')}?lang=de`, { headers: { 'Cache-Control': 'no-store' }}); - if (res.ok) { - const j = await res.json(); - setTitle(j.title || ''); - setBody(j.body_markdown || ''); - } - setLoading(false); + if (!page) { + return; } - if (page) load(); + const controller = new AbortController(); + + async function loadLegal() { + try { + setLoading(true); + const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, { + headers: { 'Cache-Control': 'no-store' }, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error('failed'); + } + const data = await res.json(); + setTitle(data.title || ''); + setBody(data.body_markdown || ''); + } catch (error) { + if (!controller.signal.aborted) { + console.error('Failed to load legal page', error); + setTitle(''); + setBody(''); + } + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + loadLegal(); + return () => controller.abort(); }, [page]); return ( - {loading ?

Lädt…

: } + {loading ?

Laedt...

: }
); } - -function Markdown({ md }: { md: string }) { - // Tiny, safe Markdown: paragraphs + basic bold/italic + links; no external dependency - const html = React.useMemo(() => { - let s = md - .replace(/&/g, '&') - .replace(//g, '>'); - // bold **text** - s = s.replace(/\*\*(.+?)\*\*/g, '$1'); - // italic *text* - s = s.replace(/(?$1'); - // links [text](url) - s = s.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '$1<\/a>'); - // paragraphs - s = s.split(/\n{2,}/).map(p => `

${p.replace(/\n/g, '
')}<\/p>`).join('\n'); - return s; - }, [md]); - return

; -} diff --git a/resources/js/guest/pages/ProfileSetupPage.tsx b/resources/js/guest/pages/ProfileSetupPage.tsx index cc3dbca..6c43481 100644 --- a/resources/js/guest/pages/ProfileSetupPage.tsx +++ b/resources/js/guest/pages/ProfileSetupPage.tsx @@ -1,13 +1,104 @@ -import React from 'react'; -import { Page } from './_util'; +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useEventData } from '../hooks/useEventData'; +import { useGuestIdentity } from '../context/GuestIdentityContext'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import Header from '../components/Header'; export default function ProfileSetupPage() { + const { slug } = useParams<{ slug: string }>(); + const nav = useNavigate(); + const { event, loading, error } = useEventData(); + const { name: storedName, setName: persistName, hydrated } = useGuestIdentity(); + const [name, setName] = useState(storedName); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!slug) { + nav('/'); + return; + } + }, [slug, nav]); + + useEffect(() => { + if (hydrated) { + setName(storedName); + } + }, [hydrated, storedName]); + + function handleChange(value: string) { + setName(value); + } + + function submitName() { + if (!slug) return; + const trimmedName = name.trim(); + if (!trimmedName) return; + + setSubmitting(true); + try { + persistName(trimmedName); + nav(`/e/${slug}`); + } catch (e) { + console.error('Fehler beim Speichern des Namens:', e); + setSubmitting(false); + } + } + + if (loading) { + return ( +
+
Lade Event...
+
+ ); + } + + if (error || !event) { + return ( +
+

{error || 'Event nicht gefunden.'}

+ +
+ ); + } + return ( - - -
- - +
+
+
+ + + {event.name} + + Fange den schoensten Moment ein! + + + +
+ + handleChange(e.target.value)} + placeholder="Dein Name" + className="text-lg" + disabled={submitting || !hydrated} + autoComplete="name" + /> +
+ +
+
+
+
); } - diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx index 5644cf2..8831077 100644 --- a/resources/js/guest/pages/TaskPickerPage.tsx +++ b/resources/js/guest/pages/TaskPickerPage.tsx @@ -1,19 +1,17 @@ -import React, { useState, useEffect } from 'react'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; -import { Page } from './_util'; +import React from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { useAppearance } from '../../hooks/use-appearance'; -import { Clock, RefreshCw, Smile } from 'lucide-react'; -import BottomNav from '../components/BottomNav'; -import { useEventData } from '../hooks/useEventData'; -import { EventData } from '../services/eventApi'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Sparkles, RefreshCw, Smile, Timer as TimerIcon, CheckCircle2, AlertTriangle } from 'lucide-react'; +import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; interface Task { id: number; title: string; description: string; instructions: string; - duration: number; // in minutes + duration: number; // minutes emotion?: { slug: string; name: string; @@ -21,244 +19,508 @@ interface Task { is_completed: boolean; } +type EmotionOption = { + slug: string; + name: string; +}; + +const TASK_PROGRESS_TARGET = 5; +const TIMER_VIBRATION = [0, 60, 120, 60]; + export default function TaskPickerPage() { const { slug } = useParams<{ slug: string }>(); - const [searchParams] = useSearchParams(); - // emotionSlug = searchParams.get('emotion'); // Temporär deaktiviert, da API-Filter nicht verfügbar const navigate = useNavigate(); - const { appearance } = useAppearance(); - const isDark = appearance === 'dark'; - - const [tasks, setTasks] = useState([]); - const [currentTask, setCurrentTask] = useState(null); - const [timeLeft, setTimeLeft] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); - // Timer state - useEffect(() => { - if (!currentTask) return; - - const durationMs = currentTask.duration * 60 * 1000; - setTimeLeft(durationMs / 1000); - - const interval = setInterval(() => { - setTimeLeft(prev => { + const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug); + + const [tasks, setTasks] = React.useState([]); + const [currentTask, setCurrentTask] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [selectedEmotion, setSelectedEmotion] = React.useState('all'); + const [timeLeft, setTimeLeft] = React.useState(0); + const [timerRunning, setTimerRunning] = React.useState(false); + const [timeUp, setTimeUp] = React.useState(false); + const [isFetching, setIsFetching] = React.useState(false); + + const recentTaskIdsRef = React.useRef([]); + const initialEmotionRef = React.useRef(false); + + const fetchTasks = React.useCallback(async () => { + if (!slug) return; + setIsFetching(true); + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`); + if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); + const payload = await response.json(); + if (Array.isArray(payload)) { + setTasks(payload); + } else { + setTasks([]); + } + } catch (err) { + console.error('Failed to load tasks', err); + setError(err instanceof Error ? err.message : 'Unbekannter Fehler'); + setTasks([]); + } finally { + setIsFetching(false); + setLoading(false); + } + }, [slug]); + + React.useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + React.useEffect(() => { + if (initialEmotionRef.current) return; + const queryEmotion = searchParams.get('emotion'); + if (queryEmotion) { + setSelectedEmotion(queryEmotion); + } + initialEmotionRef.current = true; + }, [searchParams]); + + const emotionOptions = React.useMemo(() => { + const map = new Map(); + tasks.forEach((task) => { + if (task.emotion?.slug) { + map.set(task.emotion.slug, task.emotion.name); + } + }); + return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name })); + }, [tasks]); + + const filteredTasks = React.useMemo(() => { + if (selectedEmotion === 'all') return tasks; + return tasks.filter((task) => task.emotion?.slug === selectedEmotion); + }, [tasks, selectedEmotion]); + + const selectRandomTask = React.useCallback( + (list: Task[]) => { + if (!list.length) { + setCurrentTask(null); + return; + } + const avoidIds = recentTaskIdsRef.current; + const available = list.filter((task) => !isCompleted(task.id)); + const base = available.length ? available : list; + let candidates = base.filter((task) => !avoidIds.includes(task.id)); + if (!candidates.length) { + candidates = base; + } + const chosen = candidates[Math.floor(Math.random() * candidates.length)]; + setCurrentTask(chosen); + recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3); + }, + [isCompleted] + ); + + React.useEffect(() => { + if (!filteredTasks.length) { + setCurrentTask(null); + return; + } + if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) { + selectRandomTask(filteredTasks); + return; + } + const matchingTask = filteredTasks.find((task) => task.id === currentTask.id); + const durationMinutes = matchingTask?.duration ?? currentTask.duration; + setTimeLeft(durationMinutes * 60); + setTimerRunning(false); + setTimeUp(false); + }, [filteredTasks, currentTask, selectRandomTask]); + + React.useEffect(() => { + if (!currentTask) { + setTimeLeft(0); + setTimerRunning(false); + setTimeUp(false); + return; + } + setTimeLeft(currentTask.duration * 60); + setTimerRunning(false); + setTimeUp(false); + }, [currentTask]); + + React.useEffect(() => { + if (!timerRunning) return; + if (timeLeft <= 0) { + setTimerRunning(false); + triggerTimeUp(); + return; + } + const tick = window.setInterval(() => { + setTimeLeft((prev) => { if (prev <= 1) { - clearInterval(interval); + window.clearInterval(tick); + triggerTimeUp(); return 0; } return prev - 1; }); }, 1000); + return () => window.clearInterval(tick); + }, [timerRunning, timeLeft]); - return () => clearInterval(interval); - }, [currentTask]); - - // Load tasks - useEffect(() => { - if (!slug) return; - - async function fetchTasks() { - try { - setLoading(true); - setError(null); - - const url = `/api/v1/events/${slug}/tasks`; - console.log('Fetching tasks from:', url); // Debug - - const response = await fetch(url); - if (!response.ok) throw new Error('Tasks konnten nicht geladen werden'); - - const data = await response.json(); - setTasks(Array.isArray(data) ? data : []); - - console.log('Loaded tasks:', data); // Debug - - // Select random task - if (data.length > 0) { - const randomIndex = Math.floor(Math.random() * data.length); - setCurrentTask(data[randomIndex]); - console.log('Selected random task:', data[randomIndex]); // Debug - } - } catch (err) { - console.error('Fetch tasks error:', err); - setError(err instanceof Error ? err.message : 'Unbekannter Fehler'); - } finally { - setLoading(false); - } + function triggerTimeUp() { + const supportsVibration = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'; + setTimerRunning(false); + setTimeUp(true); + if (supportsVibration) { + try { + navigator.vibrate(TIMER_VIBRATION); + } catch (error) { + console.warn('Vibration not permitted', error); } + } + window.setTimeout(() => setTimeUp(false), 4000); + return; +} - fetchTasks(); - }, [slug]); - - const formatTime = (seconds: number) => { + const formatTime = React.useCallback((seconds: number) => { const mins = Math.floor(seconds / 60); - const secs = seconds % 60; + const secs = Math.max(0, seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; + }, []); + + const progressRatio = currentTask ? Math.min(1, completedCount / TASK_PROGRESS_TARGET) : 0; + + const handleSelectEmotion = (slugValue: string) => { + setSelectedEmotion(slugValue); + const next = new URLSearchParams(searchParams.toString()); + if (slugValue === 'all') { + next.delete('emotion'); + } else { + next.set('emotion', slugValue); + } + setSearchParams(next, { replace: true }); }; const handleNewTask = () => { - if (tasks.length === 0) return; - const randomIndex = Math.floor(Math.random() * tasks.length); - setCurrentTask(tasks[randomIndex]); - setTimeLeft(tasks[randomIndex].duration * 60); + selectRandomTask(filteredTasks); }; - const handleStartTask = () => { + const handleStartUpload = () => { + if (!currentTask || !slug) return; + navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`); + }; + + const handleMarkCompleted = () => { if (!currentTask) return; - // Navigate to upload with task context - navigate(`/e/${slug}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`); + markCompleted(currentTask.id); + selectRandomTask(filteredTasks); }; - const handleChangeMood = () => { - navigate(`/e/${slug}`); + const handleRetryFetch = () => { + fetchTasks(); }; - if (loading) { - return ( - -
- -

Lade Aufgabe...

-
- -
- ); - } + const handleTimerToggle = () => { + if (!currentTask) return; + if (timerRunning) { + setTimerRunning(false); + setTimeLeft(currentTask.duration * 60); + setTimeUp(false); + } else { + if (timeLeft <= 0) { + setTimeLeft(currentTask.duration * 60); + } + setTimerRunning(true); + setTimeUp(false); + } + }; - if (error || !currentTask) { - return ( - -
- -
-

Keine passende Aufgabe gefunden

-

- {error || 'Für deine Stimmung gibt es derzeit keine Aufgaben. Versuche eine andere Stimmung oder warte auf neue Inhalte.'} -

-
- -
- -
- ); - } + const emptyState = !loading && (!filteredTasks.length || !currentTask); return ( - -
- {/* Task Header with Selfie Overlay */} -
-
- {/* Selfie Placeholder */} -
-
-
- 📸 -
-

- Selfie-Vorschau -

-
-
- - {/* Timer */} -
-
60 - ? 'bg-green-500/20 text-green-400 border-green-500/30' - : timeLeft > 30 - ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' - : 'bg-red-500/20 text-red-400 border-red-500/30' - } border`}> - - {formatTime(timeLeft)} -
-
+
+
+
+

Aufgabe auswaehlen

+ + Schon {completedCount} Aufgaben erledigt + +
+
+
+ Auf dem Weg zum naechsten Erfolg + + {completedCount >= TASK_PROGRESS_TARGET + ? 'Stark!' + : `${Math.max(0, TASK_PROGRESS_TARGET - completedCount)} bis zum Badge`} + +
+
+
+
+
+ {emotionOptions.length > 0 && ( +
+ handleSelectEmotion('all')} + /> + {emotionOptions.map((emotion) => ( + handleSelectEmotion(emotion.slug)} + /> + ))} +
+ )} +
- {/* Task Description Overlay */} -
-
-

- {currentTask.title} -

-

- {currentTask.description} -

- {currentTask.instructions && ( -
-

💡 {currentTask.instructions}

-
+ {loading && ( +
+ + +
+ )} + + {error && !loading && ( + + + {error} + + + + )} + + {emptyState && ( + + )} + + {!emptyState && currentTask && ( +
+
+
+
+
+ +

Deine Mission

+

{currentTask.title}

+
+
+
+ + {timeUp && ( + + + Zeit abgelaufen! + )}
-
-
- {/* Action Buttons */} -
- +
+
+ + + {currentTask.duration} Min + + {currentTask.emotion?.name && ( + + + {currentTask.emotion.name} + + )} + {isCompleted(currentTask.id) && ( + + + Bereits erledigt + + )} +
-
- - - +
+ +
+ +
+ )} - {/* Bottom Navigation */} - -
- + {!loading && !tasks.length && !error && ( + + Fuer dieses Event sind derzeit keine Aufgaben hinterlegt. + + )} +
+ ); +} + +function timerTone(timeLeft: number, durationMinutes: number) { + const totalSeconds = Math.max(1, durationMinutes * 60); + const ratio = timeLeft / totalSeconds; + if (ratio > 0.5) return 'okay'; + if (ratio > 0.25) return 'warm'; + return 'hot'; +} + +function EmotionChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ); +} + +function ChecklistItem({ text }: { text: string }) { + return ( +
  • + + {text} +
  • + ); +} + +function BadgeTimer({ label, value, tone }: { label: string; value: string; tone: 'okay' | 'warm' | 'hot' }) { + const toneClasses = { + okay: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/30', + warm: 'bg-amber-500/15 text-amber-500 border-amber-500/30', + hot: 'bg-rose-500/15 text-rose-500 border-rose-500/30', + }[tone]; + return ( +
    + + {label} + {value} +
    + ); +} + +function SkeletonBlock() { + return
    ; +} + +function EmptyState({ + hasTasks, + onRetry, + emotionOptions, + onEmotionSelect, +}: { + hasTasks: boolean; + onRetry: () => void; + emotionOptions: EmotionOption[]; + onEmotionSelect: (slug: string) => void; +}) { + return ( +
    + +
    +

    Keine passende Aufgabe gefunden

    +

    + {hasTasks + ? 'Fuer deine aktuelle Stimmung gibt es gerade keine Aufgabe. Waehle eine andere Stimmung oder lade neue Aufgaben.' + : 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es spaeter erneut.'} +

    +
    + {hasTasks && emotionOptions.length > 0 && ( +
    + {emotionOptions.map((emotion) => ( + onEmotionSelect(emotion.slug)} + /> + ))} +
    + )} + +
    ); } diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 14637df..2f25b59 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -1,11 +1,26 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; -import { Page } from './_util'; -import { Button } from '@/components/ui/button'; -import { useAppearance } from '../../hooks/use-appearance'; -import { Camera, RotateCcw, Zap, ZapOff } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import Header from '../components/Header'; import BottomNav from '../components/BottomNav'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { uploadPhoto } from '../services/photosApi'; +import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; +import { useAppearance } from '../../hooks/use-appearance'; +import { cn } from '@/lib/utils'; +import { + AlertTriangle, + Camera, + Grid3X3, + ImagePlus, + Info, + Loader2, + RotateCcw, + Sparkles, + Zap, + ZapOff, +} from 'lucide-react'; interface Task { id: number; @@ -17,455 +32,777 @@ interface Task { difficulty?: 'easy' | 'medium' | 'hard'; } +type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported'; +type CameraMode = 'preview' | 'countdown' | 'review' | 'uploading'; + +type CameraPreferences = { + facingMode: 'user' | 'environment'; + countdownSeconds: number; + countdownEnabled: boolean; + gridEnabled: boolean; + mirrorFrontPreview: boolean; + flashPreferred: boolean; +}; + +const DEFAULT_PREFS: CameraPreferences = { + facingMode: 'environment', + countdownSeconds: 3, + countdownEnabled: true, + gridEnabled: true, + mirrorFrontPreview: true, + flashPreferred: false, +}; + export default function UploadPage() { const { slug } = useParams<{ slug: string }>(); - const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { appearance } = useAppearance(); - const isDark = appearance === 'dark'; + const isDarkMode = appearance === 'dark'; + const { markCompleted } = useGuestTaskProgress(slug); - // Task data from URL params - const taskId = searchParams.get('task'); + const taskIdParam = searchParams.get('task'); const emotionSlug = searchParams.get('emotion') || ''; + + const primerStorageKey = slug ? `guestCameraPrimerDismissed_${slug}` : 'guestCameraPrimerDismissed'; + const prefsStorageKey = slug ? `guestCameraPrefs_${slug}` : 'guestCameraPrefs'; + + const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; + const [task, setTask] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [uploading, setUploading] = useState(false); + const [loadingTask, setLoadingTask] = useState(true); + const [taskError, setTaskError] = useState(null); - // Camera state - const videoRef = useRef(null); - const canvasRef = useRef(null); - const [stream, setStream] = useState(null); - const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user'); // front = user, back = environment - const [flashOn, setFlashOn] = useState(false); - const [isPulsing, setIsPulsing] = useState(false); - const [countdown, setCountdown] = useState(3); - const [capturing, setCapturing] = useState(false); + const [permissionState, setPermissionState] = useState('idle'); + const [permissionMessage, setPermissionMessage] = useState(null); - // Load task data from API + const [preferences, setPreferences] = useState(DEFAULT_PREFS); + const [mode, setMode] = useState('preview'); + const [countdownValue, setCountdownValue] = useState(DEFAULT_PREFS.countdownSeconds); + const [statusMessage, setStatusMessage] = useState(''); + + const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadError, setUploadError] = useState(null); + + const [showPrimer, setShowPrimer] = useState(() => { + if (typeof window === 'undefined') return false; + return window.localStorage.getItem(primerStorageKey) !== '1'; + }); + + const videoRef = useRef(null); + const canvasRef = useRef(null); + const fileInputRef = useRef(null); + const liveRegionRef = useRef(null); + + const streamRef = useRef(null); + const countdownTimerRef = useRef(null); + const uploadProgressTimerRef = useRef(null); + + const taskId = useMemo(() => { + if (!taskIdParam) return null; + const parsed = parseInt(taskIdParam, 10); + return Number.isFinite(parsed) ? parsed : null; + }, [taskIdParam]); + + // Load preferences from storage + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const stored = window.localStorage.getItem(prefsStorageKey); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + setPreferences((prev) => ({ ...prev, ...parsed })); + } + } catch (error) { + console.warn('Failed to parse camera preferences', error); + } + }, [prefsStorageKey]); + + // Persist preferences when they change + useEffect(() => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(prefsStorageKey, JSON.stringify(preferences)); + } catch (error) { + console.warn('Failed to persist camera preferences', error); + } + }, [preferences, prefsStorageKey]); + + // Load task metadata useEffect(() => { if (!slug || !taskId) { - setError('Keine Aufgabendaten gefunden'); - setLoading(false); + setTaskError('Keine Aufgabeninformationen gefunden.'); + setLoadingTask(false); return; } - const taskIdNum = parseInt(taskId); - if (isNaN(taskIdNum)) { - setError('Ungültige Aufgaben-ID'); - setLoading(false); - return; - } + let active = true; - async function fetchTask() { + async function loadTask() { try { - setLoading(true); - setError(null); - - const response = await fetch(`/api/v1/events/${slug}/tasks`); - if (!response.ok) throw new Error('Tasks konnten nicht geladen werden'); - - const tasks = await response.json(); - const foundTask = tasks.find((t: any) => t.id === taskIdNum); - - if (foundTask) { + setLoadingTask(true); + setTaskError(null); + + const res = await fetch(`/api/v1/events/${encodeURIComponent(slug!)}/tasks`); + if (!res.ok) throw new Error('Tasks konnten nicht geladen werden'); + const tasks = await res.json(); + const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null; + + if (!active) return; + + if (found) { setTask({ - id: foundTask.id, - title: foundTask.title || `Aufgabe ${taskIdNum}`, - description: foundTask.description || 'Stelle dich für das Foto auf und lächle in die Kamera.', - instructions: foundTask.instructions, - duration: foundTask.duration || 2, - emotion: foundTask.emotion, - difficulty: 'medium' as const + id: found.id, + title: found.title || `Aufgabe ${taskId!}`, + description: found.description || 'Halte den Moment fest und teile ihn mit allen Gästen.', + instructions: found.instructions, + duration: found.duration || 2, + emotion: found.emotion, + difficulty: found.difficulty ?? 'medium', }); } else { - // Fallback for unknown task ID setTask({ - id: taskIdNum, - title: `Unbekannte Aufgabe ${taskIdNum}`, - description: 'Stelle dich für das Foto auf und lächle in die Kamera.', - instructions: 'Positioniere dich gut und warte auf den Countdown.', + id: taskId!, + title: `Aufgabe ${taskId!}`, + description: 'Halte den Moment fest und teile ihn mit allen Gästen.', + instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.', duration: 2, - emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase()) } : undefined, - difficulty: 'medium' as const + emotion: emotionSlug + ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } + : undefined, + difficulty: 'medium', + }); + } + } catch (error) { + console.error('Failed to fetch task', error); + if (active) { + setTaskError('Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.'); + setTask({ + id: taskId!, + title: `Aufgabe ${taskId!}`, + description: 'Halte den Moment fest und teile ihn mit allen Gästen.', + instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.', + duration: 2, + emotion: emotionSlug + ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } + : undefined, + difficulty: 'medium', }); } - } catch (err) { - console.error('Failed to fetch task:', err); - setError('Aufgabe konnte nicht geladen werden'); - // Set fallback task - setTask({ - id: taskIdNum, - title: `Unbekannte Aufgabe ${taskIdNum}`, - description: 'Stelle dich für das Foto auf und lächle in die Kamera.', - instructions: 'Positioniere dich gut und warte auf den Countdown.', - duration: 2, - emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase()) } : undefined, - difficulty: 'medium' as const - }); } finally { - setLoading(false); + if (active) setLoadingTask(false); } } - fetchTask(); + loadTask(); + return () => { + active = false; + }; }, [slug, taskId, emotionSlug]); - // Camera setup - useEffect(() => { - if (!slug || loading || !task) return; + const stopStream = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + }, []); - const setupCamera = async () => { - try { - const constraints: MediaStreamConstraints = { - video: { - width: { ideal: 1280 }, - height: { ideal: 720 }, - facingMode: facingMode ? { ideal: facingMode } : undefined - } - }; - - console.log('Requesting camera with constraints:', constraints); - - const newStream = await navigator.mediaDevices.getUserMedia(constraints); + const attachStreamToVideo = useCallback((stream: MediaStream) => { + if (!videoRef.current) return; + videoRef.current.srcObject = stream; + videoRef.current + .play() + .then(() => { if (videoRef.current) { - videoRef.current.srcObject = newStream; - videoRef.current.play().catch(e => console.error('Video play error:', e)); - // Set video dimensions after metadata is loaded - videoRef.current.onloadedmetadata = () => { - if (videoRef.current) { - videoRef.current.style.display = 'block'; - } - }; + videoRef.current.muted = true; } - setStream(newStream); - setError(null); // Clear any previous errors - } catch (err: any) { - console.error('Camera access error:', err.name, err.message); - - let errorMessage = 'Kamera konnte nicht gestartet werden.'; - - switch (err.name) { - case 'NotAllowedError': - errorMessage = 'Kamera-Zugriff verweigert.\n\n' + - '• Chrome: Adressleiste klicken → Kamera-Symbol → "Zulassen"\n' + - '• Safari: Einstellungen → Website-Einstellungen → Kamera → "Erlauben"\n' + - '• Firefox: Adressleiste → Berechtigungen → Kamera → "Erlauben"\n\n' + - 'Danach Seite neu laden.'; - break; - case 'NotFoundError': - errorMessage = 'Keine Kamera gefunden. Bitte überprüfen Sie:\n' + - '• Ob eine Kamera am Gerät verfügbar ist\n' + - '• Ob andere Apps die Kamera verwenden\n' + - '• Gerätekonfiguration in den Browser-Einstellungen'; - break; - case 'NotSupportedError': - errorMessage = 'Kamera nicht unterstützt. Bitte verwenden Sie:\n' + - '• Chrome, Firefox oder Safari (neueste Version)\n' + - '• HTTPS-Verbindung (nicht HTTP)'; - break; - case 'OverconstrainedError': - errorMessage = 'Kamera-Einstellungen nicht verfügbar. Versuche mit Standard-Einstellungen...'; - // Fallback to basic constraints - try { - const fallbackConstraints = { video: true }; - const fallbackStream = await navigator.mediaDevices.getUserMedia(fallbackConstraints); - if (videoRef.current) { - videoRef.current.srcObject = fallbackStream; - videoRef.current.play(); - } - setStream(fallbackStream); - setError(null); - return; - } catch (fallbackErr) { - console.error('Fallback camera failed:', fallbackErr); - } - break; - default: - errorMessage = `Kamera-Fehler (${err.name}): ${err.message}\n\nBitte versuchen Sie:\n• Seite neu laden\n• Browser neu starten\n• Anderen Browser verwenden`; - } - - setError(errorMessage); + }) + .catch((error) => console.error('Video play error', error)); + }, []); + + const createConstraint = useCallback( + (mode: 'user' | 'environment'): MediaStreamConstraints => ({ + video: { + width: { ideal: 1920 }, + height: { ideal: 1080 }, + facingMode: { ideal: mode }, + }, + audio: false, + }), + [] + ); + + const startCamera = useCallback(async () => { + if (!supportsCamera) { + setPermissionState('unsupported'); + setPermissionMessage('Dieses Gerät oder der Browser unterstützt keine Kamera-Zugriffe.'); + return; + } + + if (!task || mode === 'uploading') return; + + try { + setPermissionState('prompt'); + setPermissionMessage(null); + + const stream = await navigator.mediaDevices.getUserMedia(createConstraint(preferences.facingMode)); + stopStream(); + streamRef.current = stream; + attachStreamToVideo(stream); + setPermissionState('granted'); + } catch (error: any) { + console.error('Camera access error', error); + stopStream(); + + if (error?.name === 'NotAllowedError') { + setPermissionState('denied'); + setPermissionMessage( + 'Kamera-Zugriff wurde blockiert. Prüfe die Berechtigungen deines Browsers und versuche es erneut.' + ); + } else if (error?.name === 'NotFoundError') { + setPermissionState('error'); + setPermissionMessage('Keine Kamera gefunden. Du kannst stattdessen ein Foto aus deiner Galerie wählen.'); + } else { + setPermissionState('error'); + setPermissionMessage(`Kamera konnte nicht gestartet werden: ${error?.message || 'Unbekannter Fehler'}`); } - }; - - setupCamera(); + } + }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task]); + useEffect(() => { + if (!task || loadingTask) return; + startCamera(); return () => { - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); - } + stopStream(); }; - }, [slug, loading, task, facingMode]); + }, [task, loadingTask, startCamera, stopStream, preferences.facingMode]); - // Handle capture - const handleCapture = useCallback(async () => { - if (!videoRef.current || !canvasRef.current || !task) return; + // Countdown live region updates + useEffect(() => { + if (!liveRegionRef.current) return; + if (mode === 'countdown') { + liveRegionRef.current.textContent = `Foto wird in ${countdownValue} Sekunden aufgenommen.`; + } else if (mode === 'review') { + liveRegionRef.current.textContent = 'Foto aufgenommen. �berpr�fe die Vorschau.'; + } else if (mode === 'uploading') { + liveRegionRef.current.textContent = 'Foto wird hochgeladen.'; + } else { + liveRegionRef.current.textContent = ''; + } + }, [mode, countdownValue]); - setCapturing(true); - setIsPulsing(false); + const dismissPrimer = useCallback(() => { + setShowPrimer(false); + if (typeof window !== 'undefined') { + window.localStorage.setItem(primerStorageKey, '1'); + } + }, [primerStorageKey]); - // Start countdown - let count = 3; - setCountdown(count); - - const countdownInterval = setInterval(() => { - count--; - setCountdown(count); - if (count <= 0) { - clearInterval(countdownInterval); - - // Capture photo - const video = videoRef.current; - const canvas = canvasRef.current; - if (!canvas) return; - - const context = canvas.getContext('2d'); - if (!context || !video) return; - - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - context.drawImage(video, 0, 0); - - // Convert to blob - canvas.toBlob(async (blob) => { - if (blob && task && slug) { - try { - // Show uploading state - setUploading(true); - setCapturing(false); - setCountdown(3); - setError(null); - - // Use emotionSlug directly (backend expects string slug) - - // Convert Blob to File with proper filename - const timestamp = Date.now(); - const fileName = `photo-${timestamp}-${task.id}.jpg`; - const file = new File([blob], fileName, { - type: 'image/jpeg', - lastModified: timestamp - }); - - console.log('Uploading photo:', { - taskId: task.id, - emotionSlug, - fileName, - fileSize: file.size - }); - - // Upload the photo - const photoId = await uploadPhoto(slug, file, task.id, emotionSlug); - - console.log('Upload successful, photo ID:', photoId); - - // Navigate to gallery with success - navigate(`/e/${slug}/gallery?task=${task.id}&emotion=${emotionSlug}&uploaded=true`); - } catch (error: any) { - console.error('Upload failed:', error); - setError(`Upload fehlgeschlagen: ${error.message}\n\nFoto wurde erstellt, aber nicht hochgeladen.\nVersuchen Sie es erneut oder wählen Sie ein anderes Foto aus.`); - } finally { - setUploading(false); - } + const handleToggleGrid = useCallback(() => { + setPreferences((prev) => ({ ...prev, gridEnabled: !prev.gridEnabled })); + }, []); + + const handleToggleMirror = useCallback(() => { + setPreferences((prev) => ({ ...prev, mirrorFrontPreview: !prev.mirrorFrontPreview })); + }, []); + + const handleToggleCountdown = useCallback(() => { + setPreferences((prev) => ({ ...prev, countdownEnabled: !prev.countdownEnabled })); + }, []); + + const handleSwitchCamera = useCallback(() => { + setPreferences((prev) => ({ + ...prev, + facingMode: prev.facingMode === 'user' ? 'environment' : 'user', + })); + }, []); + + const handleToggleFlashPreference = useCallback(() => { + setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred })); + }, []); + + const resetCountdownTimer = useCallback(() => { + if (countdownTimerRef.current) { + window.clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + }, []); + + const performCapture = useCallback(() => { + if (!videoRef.current || !canvasRef.current) { + setUploadError('Kamera nicht bereit. Bitte versuche es erneut.'); + setMode('preview'); + return; + } + + const video = videoRef.current; + const canvas = canvasRef.current; + const width = video.videoWidth; + const height = video.videoHeight; + + if (!width || !height) { + setUploadError('Kamera liefert kein Bild. Bitte starte die Kamera neu.'); + setMode('preview'); + startCamera(); + return; + } + + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + setUploadError('Canvas konnte nicht initialisiert werden.'); + setMode('preview'); + return; + } + + context.save(); + const shouldMirror = preferences.facingMode === 'user' && preferences.mirrorFrontPreview; + if (shouldMirror) { + context.scale(-1, 1); + context.drawImage(video, -width, 0, width, height); + } else { + context.drawImage(video, 0, 0, width, height); + } + context.restore(); + + canvas.toBlob( + (blob) => { + if (!blob) { + setUploadError('Foto konnte nicht erstellt werden.'); + setMode('preview'); + return; + } + const timestamp = Date.now(); + const fileName = `photo-${timestamp}.jpg`; + const file = new File([blob], fileName, { type: 'image/jpeg', lastModified: timestamp }); + const dataUrl = canvas.toDataURL('image/jpeg', 0.92); + setReviewPhoto({ dataUrl, file }); + setMode('review'); + }, + 'image/jpeg', + 0.92 + ); + }, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera]); + + const beginCapture = useCallback(() => { + setUploadError(null); + if (preferences.countdownEnabled && preferences.countdownSeconds > 0) { + setMode('countdown'); + setCountdownValue(preferences.countdownSeconds); + resetCountdownTimer(); + countdownTimerRef.current = window.setInterval(() => { + setCountdownValue((prev) => { + if (prev <= 1) { + resetCountdownTimer(); + performCapture(); + return preferences.countdownSeconds; } - }, 'image/jpeg', 0.8); - - setCapturing(false); - setCountdown(3); + return prev - 1; + }); + }, 1000); + } else { + performCapture(); + } + }, [performCapture, preferences.countdownEnabled, preferences.countdownSeconds, resetCountdownTimer]); + + const handleRetake = useCallback(() => { + setReviewPhoto(null); + setUploadProgress(0); + setUploadError(null); + setMode('preview'); + }, []); + + const navigateAfterUpload = useCallback( + (photoId: number | undefined) => { + if (!slug || !task) return; + const params = new URLSearchParams(); + params.set('uploaded', 'true'); + if (task.id) params.set('task', String(task.id)); + if (photoId) params.set('photo', String(photoId)); + if (emotionSlug) params.set('emotion', emotionSlug); + navigate(`/e/${encodeURIComponent(slug!)}/gallery?${params.toString()}`); + }, + [emotionSlug, navigate, slug, task] + ); + + const handleUsePhoto = useCallback(async () => { + if (!slug || !reviewPhoto || !task) return; + setMode('uploading'); + setUploadProgress(5); + setUploadError(null); + setStatusMessage('Foto wird vorbereitet...'); + + if (uploadProgressTimerRef.current) { + window.clearInterval(uploadProgressTimerRef.current); + } + uploadProgressTimerRef.current = window.setInterval(() => { + setUploadProgress((prev) => (prev < 90 ? prev + 5 : prev)); + }, 400); + + try { + const photoId = await uploadPhoto(slug, reviewPhoto.file, task.id, emotionSlug || undefined); + setUploadProgress(100); + setStatusMessage('Upload abgeschlossen.'); + markCompleted(task.id); + stopStream(); + navigateAfterUpload(photoId); + } catch (error: any) { + console.error('Upload failed', error); + setUploadError(error?.message || 'Upload fehlgeschlagen. Bitte versuche es erneut.'); + setMode('review'); + } finally { + if (uploadProgressTimerRef.current) { + window.clearInterval(uploadProgressTimerRef.current); + uploadProgressTimerRef.current = null; } - }, 1000); + setStatusMessage(''); + } + }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task]); - // Start pulsing animation - setIsPulsing(true); - }, [task, emotionSlug, slug, navigate]); + const handleGalleryPick = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + setUploadError(null); + const reader = new FileReader(); + reader.onload = () => { + setReviewPhoto({ dataUrl: reader.result as string, file }); + setMode('review'); + }; + reader.onerror = () => { + setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.'); + }; + reader.readAsDataURL(file); + }, []); - // Switch camera - const switchCamera = () => { - setFacingMode(prev => prev === 'user' ? 'environment' : 'user'); - }; + const difficultyBadgeClass = useMemo(() => { + if (!task) return 'text-white'; + switch (task.difficulty) { + case 'easy': + return 'text-emerald-400'; + case 'hard': + return 'text-rose-400'; + default: + return 'text-amber-300'; + } + }, [task]); - // Toggle flash (for back camera only) - const toggleFlash = () => { - if (facingMode !== 'environment') return; - setFlashOn(prev => !prev); - // TODO: Implement actual flash control if possible - }; + const isCameraActive = permissionState === 'granted' && mode !== 'uploading'; + const showTaskOverlay = task && mode !== 'uploading'; - if (loading) { + useEffect(() => () => { + resetCountdownTimer(); + if (uploadProgressTimerRef.current) { + window.clearInterval(uploadProgressTimerRef.current); + } + }, [resetCountdownTimer]); + + if (!supportsCamera && !task) { return ( - -
    - -

    Kamera wird gestartet...

    -
    - -
    - ); - } - - if (error || !task) { - return ( - -
    - -
    -

    Kamera nicht verfügbar

    -

    {error}

    - -
    -
    - -
    - ); - } - - if (uploading) { - return ( - -
    -
    -

    Foto wird hochgeladen

    -

    Bitte warten... Dies kann einen Moment dauern.

    - -
    - -
    - ); - } - - const difficultyColor = task.difficulty === 'easy' ? 'text-green-400' : - task.difficulty === 'medium' ? 'text-yellow-400' : 'text-red-400'; - - return ( - -
    - {/* Camera Preview Container */} -
    - {/* Video Background */} -