feat: implement tenant OAuth flow and guest achievements

This commit is contained in:
2025-09-25 08:32:37 +02:00
parent ef6203c603
commit b22d91ed32
84 changed files with 5984 additions and 1399 deletions

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ yarn-error.log
tools/git-askpass.ps1 tools/git-askpass.ps1
docker docker
podman-compose.dev.yml podman-compose.dev.yml
test-results

View File

@@ -279,7 +279,6 @@ class EventPublicController extends BaseController
'photos.likes_count', 'photos.likes_count',
'photos.emotion_id', 'photos.emotion_id',
'photos.task_id', 'photos.task_id',
'photos.created_at',
'photos.guest_name', 'photos.guest_name',
'tasks.title as task_title' '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) 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);
}
} }

View File

@@ -108,7 +108,7 @@ class PhotoController extends Controller
'width' => null, // To be filled by image processing 'width' => null, // To be filled by image processing
'height' => null, 'height' => null,
'status' => 'pending', // Requires moderation 'status' => 'pending', // Requires moderation
'uploader_id' => $request->user()->id ?? null, 'uploader_id' => null,
'ip_address' => $request->ip(), 'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(), 'user_agent' => $request->userAgent(),
]); ]);
@@ -170,8 +170,8 @@ class PhotoController extends Controller
]); ]);
// Only tenant admins can moderate // Only tenant admins can moderate
if (isset($validated['status']) && $request->user()->role !== 'admin') { if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
return response()->json(['error' => 'Insufficient permissions for moderation'], 403); return response()->json(['error' => 'Insufficient scopes'], 403);
} }
$photo->update($validated); $photo->update($validated);
@@ -243,7 +243,7 @@ class PhotoController extends Controller
'status' => 'approved', 'status' => 'approved',
'moderation_notes' => $request->moderation_notes, 'moderation_notes' => $request->moderation_notes,
'moderated_at' => now(), 'moderated_at' => now(),
'moderated_by' => $request->user()->id, 'moderated_by' => null,
]); ]);
// Load approved photos for response // Load approved photos for response
@@ -288,7 +288,7 @@ class PhotoController extends Controller
'status' => 'rejected', 'status' => 'rejected',
'moderation_notes' => $request->moderation_notes, 'moderation_notes' => $request->moderation_notes,
'moderated_at' => now(), 'moderated_at' => now(),
'moderated_by' => $request->user()->id, 'moderated_by' => null,
]); ]);
// Optionally delete rejected photos from storage // 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) * Generate presigned S3 URL for direct upload (alternative to local storage)
*/ */
@@ -468,3 +479,9 @@ class PhotoController extends Controller
], 201); ], 201);
} }
} }

View File

@@ -8,18 +8,22 @@ use App\Models\RefreshToken;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantToken; use App\Models\TenantToken;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Firebase\JWT\JWT; use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class OAuthController extends Controller 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 * Authorize endpoint - PKCE flow
*/ */
@@ -28,6 +32,7 @@ class OAuthController extends Controller
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'client_id' => 'required|string', 'client_id' => 'required|string',
'redirect_uri' => 'required|url', 'redirect_uri' => 'required|url',
'response_type' => 'required|in:code',
'scope' => 'required|string', 'scope' => 'required|string',
'state' => 'nullable|string', 'state' => 'nullable|string',
'code_challenge' => 'required|string', 'code_challenge' => 'required|string',
@@ -38,244 +43,425 @@ class OAuthController extends Controller
return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); 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) ->where('is_active', true)
->first(); ->first();
if (!$client) { if (! $client) {
return $this->errorResponse('Invalid client', 401); return $this->errorResponse('Invalid client', 401);
} }
// Validate redirect URI $allowedRedirects = (array) $client->redirect_uris;
$redirectUris = is_array($client->redirect_uris) ? $client->redirect_uris : json_decode($client->redirect_uris, true); if (! in_array($request->redirect_uri, $allowedRedirects, true)) {
if (!in_array($request->redirect_uri, $redirectUris)) {
return $this->errorResponse('Invalid redirect URI', 400); return $this->errorResponse('Invalid redirect URI', 400);
} }
// Validate scopes $requestedScopes = $this->parseScopes($request->string('scope'));
$requestedScopes = explode(' ', $request->scope); $availableScopes = (array) $client->scopes;
$availableScopes = is_array($client->scopes) ? array_keys($client->scopes) : []; if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) {
$validScopes = array_intersect($requestedScopes, $availableScopes);
if (count($validScopes) !== count($requestedScopes)) {
return $this->errorResponse('Invalid scopes requested', 400); return $this->errorResponse('Invalid scopes requested', 400);
} }
// Generate authorization code (PKCE validated later) $tenantId = $client->tenant_id ?? Tenant::query()->orderBy('id')->value('id');
$code = Str::random(40); if (! $tenantId) {
$codeId = Str::uuid(); return $this->errorResponse('Unable to resolve tenant for client', 500);
}
// Store code in Redis with 5min TTL $code = Str::random(64);
Cache::put("oauth_code:{$codeId}", [ $codeId = (string) Str::uuid();
'code' => $code, $expiresAt = now()->addMinutes(self::AUTH_CODE_TTL_MINUTES);
'client_id' => $request->client_id, $cacheKey = $this->cacheKeyForCode($code);
Cache::put($cacheKey, [
'id' => $codeId,
'client_id' => $client->client_id,
'tenant_id' => $tenantId,
'redirect_uri' => $request->redirect_uri, 'redirect_uri' => $request->redirect_uri,
'scopes' => $validScopes, 'scopes' => $requestedScopes,
'state' => $request->state ?? null, 'state' => $request->state,
'code_challenge' => $request->code_challenge, 'code_challenge' => $request->code_challenge,
'code_challenge_method' => $request->code_challenge_method, 'code_challenge_method' => $request->code_challenge_method,
'expires_at' => now()->addMinutes(5), 'expires_at' => $expiresAt,
], now()->addMinutes(5)); ], $expiresAt);
// Save to DB for persistence
OAuthCode::create([ OAuthCode::create([
'id' => $codeId, 'id' => $codeId,
'client_id' => $client->client_id,
'user_id' => (string) $tenantId,
'code' => Hash::make($code), 'code' => Hash::make($code),
'client_id' => $request->client_id, 'code_challenge' => $request->code_challenge,
'scopes' => json_encode($validScopes), 'state' => $request->state,
'state' => $request->state ?? null, 'redirect_uri' => $request->redirect_uri,
'expires_at' => now()->addMinutes(5), 'scope' => implode(' ', $requestedScopes),
'expires_at' => $expiresAt,
]); ]);
$redirectUrl = $request->redirect_uri . '?' . http_build_query([ $redirectUrl = $request->redirect_uri.'?'.http_build_query([
'code' => $code, '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) 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(), [ $validator = Validator::make($request->all(), [
'grant_type' => 'required|in:authorization_code', 'grant_type' => 'required|in:authorization_code',
'code' => 'required|string', 'code' => 'required|string',
'client_id' => 'required|string', 'client_id' => 'required|string',
'redirect_uri' => 'required|url', 'redirect_uri' => 'required|url',
'code_verifier' => 'required|string', // PKCE 'code_verifier' => 'required|string',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
} }
// Find the code in cache/DB $cacheKey = $this->cacheKeyForCode($request->code);
$cachedCode = Cache::get($request->code); $cachedCode = Cache::get($cacheKey);
if (!$cachedCode) { 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); return $this->errorResponse('Invalid authorization code', 400);
} }
$codeId = array_search($request->code, Cache::get($request->code)['code'] ?? []); /** @var OAuthClient|null $client */
if (!$codeId) { $client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
$oauthCode = OAuthCode::where('code', 'LIKE', '%' . $request->code . '%')->first(); if (! $client) {
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) {
return $this->errorResponse('Invalid client', 401); return $this->errorResponse('Invalid client', 401);
} }
// PKCE validation if ($request->redirect_uri !== Arr::get($cachedCode, 'redirect_uri')) {
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']) {
return $this->errorResponse('Invalid redirect URI', 400); return $this->errorResponse('Invalid redirect URI', 400);
} }
// Code used - delete/invalidate $codeChallengeMethod = Arr::get($cachedCode, 'code_challenge_method', 'S256');
Cache::forget("oauth_code:{$codeId}"); $expectedChallenge = $codeChallengeMethod === 'S256'
OAuthCode::where('id', $codeId)->delete(); ? $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); return $this->errorResponse('Tenant not found', 404);
} }
// Generate JWT Access Token (RS256) $scopes = Arr::get($cachedCode, 'scopes', []);
$privateKey = file_get_contents(storage_path('app/private.key')); // Ensure keys exist if (empty($scopes)) {
if (!$privateKey) { $scopes = $this->parseScopes($oauthCode->scope);
$this->generateKeyPair();
$privateKey = file_get_contents(storage_path('app/private.key'));
} }
$accessToken = $this->generateJWT($tenant->id, $cachedCode['scopes'], 'access', 3600); // 1h Cache::forget($cacheKey);
$refreshTokenId = Str::uuid(); $oauthCode->delete();
$refreshToken = Str::random(60);
// Store refresh token $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
RefreshToken::create([
'id' => $refreshTokenId, return response()->json($tokenResponse);
'token' => Hash::make($refreshToken), }
'tenant_id' => $tenant->id,
'client_id' => $request->client_id, private function handleRefreshTokenGrant(Request $request)
'scopes' => json_encode($cachedCode['scopes']), {
'expires_at' => now()->addDays(30), $validator = Validator::make($request->all(), [
'ip_address' => $request->ip(), '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', 'token_type' => 'Bearer',
'access_token' => $accessToken, 'access_token' => $accessToken,
'refresh_token' => $refreshToken, 'refresh_token' => $refreshToken,
'expires_in' => 3600, 'expires_in' => $expiresIn,
'scope' => implode(' ', $cachedCode['scopes']), 'scope' => implode(' ', $scopes),
]); ];
} }
/** private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
* Get tenant info
*/
public function me(Request $request)
{ {
$user = $request->user(); $refreshTokenId = (string) Str::uuid();
if (!$user) { $secret = Str::random(64);
return $this->errorResponse('Unauthenticated', 401); $composite = $refreshTokenId.'|'.$secret;
} $expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
$tenant = $user->tenant; RefreshToken::create([
if (!$tenant) { 'id' => $refreshTokenId,
return $this->errorResponse('Tenant not found', 404);
}
return response()->json([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'name' => $tenant->name, 'client_id' => $client->client_id,
'slug' => $tenant->slug, 'token' => Hash::make($secret),
'event_credits_balance' => $tenant->event_credits_balance, 'access_token' => $accessTokenJti,
'subscription_tier' => $tenant->subscription_tier, 'expires_at' => $expiresAt,
'subscription_expires_at' => $tenant->subscription_expires_at, 'scope' => implode(' ', $scopes),
'features' => $tenant->features, 'ip_address' => $request->ip(),
'scopes' => $request->user()->token()->abilities, 'user_agent' => $request->userAgent(),
]); ]);
return $composite;
} }
/** private function generateJWT(
* Generate JWT token int $tenantId,
*/ string $clientId,
private function generateJWT($tenantId, $scopes, $type, $expiresIn) array $scopes,
{ string $type,
int $expiresIn,
string $jti,
int $issuedAt,
int $expiresAt
): string {
[$publicKey, $privateKey] = $this->ensureKeysExist();
$payload = [ $payload = [
'iss' => url('/'), 'iss' => url('/'),
'aud' => 'fotospiel-api', 'aud' => $clientId,
'iat' => time(), 'iat' => $issuedAt,
'nbf' => time(), 'nbf' => $issuedAt,
'exp' => time() + $expiresIn, 'exp' => $expiresAt,
'sub' => $tenantId, 'sub' => $tenantId,
'tenant_id' => $tenantId,
'scopes' => $scopes, 'scopes' => $scopes,
'type' => $type, 'type' => $type,
'client_id' => $clientId,
'jti' => $jti,
]; ];
$publicKey = file_get_contents(storage_path('app/public.key')); return JWT::encode($payload, $privateKey, 'RS256', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]);
$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']);
} }
/** private function ensureKeysExist(): array
* Generate RSA key pair for JWT {
*/ $publicKeyPath = storage_path('app/public.key');
private function generateKeyPair() $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 = [ $config = [
"digest_alg" => OPENSSL_ALGO_SHA256, 'digest_alg' => OPENSSL_ALGO_SHA256,
"private_key_bits" => 2048, 'private_key_bits' => 2048,
"private_key_type" => OPENSSL_KEYTYPE_RSA, 'private_key_type' => OPENSSL_KEYTYPE_RSA,
]; ];
$res = openssl_pkey_new($config); $res = openssl_pkey_new($config);
if (!$res) { if (! $res) {
throw new \Exception('Failed to generate key pair'); throw new \RuntimeException('Failed to generate key pair');
} }
// Get private key openssl_pkey_export($res, $privKey);
openssl_pkey_export($res, $privateKey);
file_put_contents(storage_path('app/private.key'), $privateKey);
// Get public key
$pubKey = openssl_pkey_get_details($res); $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;
} }
/** private function parseScopes(?string $scopeString): array
* Error response helper {
*/ if (! $scopeString) {
private function errorResponse($message, $status = 400, $errors = null) return [];
}
return array_values(array_filter(explode(' ', trim($scopeString))));
}
private function errorResponse(string $message, int $status = 400, $errors = null)
{ {
$response = ['error' => $message]; $response = ['error' => $message];
if ($errors) { if ($errors) {
@@ -285,21 +471,23 @@ class OAuthController extends Controller
return response()->json($response, $status); return response()->json($response, $status);
} }
/** private function base64urlEncode(string $data): string
* Helper function for base64url encoding
*/
private function base64url_encode($data)
{ {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
} }
private function cacheKeyForCode(string $code): string
{
return 'oauth:code:'.hash('sha256', $code);
}
/** /**
* Stripe Connect OAuth - Start connection * Stripe Connect OAuth - Start connection
*/ */
public function stripeConnect(Request $request) public function stripeConnect(Request $request)
{ {
$tenant = $request->user()->tenant; $tenant = $request->user()->tenant ?? null;
if (!$tenant) { if (! $tenant) {
return response()->json(['error' => 'Tenant not found'], 404); return response()->json(['error' => 'Tenant not found'], 404);
} }
@@ -311,7 +499,7 @@ class OAuthController extends Controller
$redirectUri = url('/api/v1/oauth/stripe-callback'); $redirectUri = url('/api/v1/oauth/stripe-callback');
$scopes = 'read_write_payments transfers'; $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); return redirect($authUrl);
} }
@@ -326,16 +514,15 @@ class OAuthController extends Controller
$error = $request->get('error'); $error = $request->get('error');
if ($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'); return redirect('/admin')->with('error', 'Invalid callback parameters');
} }
// Validate state
$sessionState = session('stripe_state'); $sessionState = session('stripe_state');
if (!hash_equals($state, $sessionState)) { if (! hash_equals($state, (string) $sessionState)) {
return redirect('/admin')->with('error', 'Invalid state parameter'); 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'); return redirect('/admin')->with('error', 'Failed to connect Stripe account');
} }
@@ -369,8 +556,8 @@ class OAuthController extends Controller
session()->forget(['stripe_state', 'tenant_id']); session()->forget(['stripe_state', 'tenant_id']);
return redirect('/admin')->with('success', 'Stripe account connected successfully'); return redirect('/admin')->with('success', 'Stripe account connected successfully');
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Stripe OAuth error: ' . $e->getMessage()); Log::error('Stripe OAuth error: '.$e->getMessage());
return redirect('/admin')->with('error', 'Connection error: ' . $e->getMessage()); return redirect('/admin')->with('error', 'Connection error: '.$e->getMessage());
} }
} }
} }

View File

@@ -8,18 +8,15 @@ use App\Http\Resources\Tenant\EventPurchaseResource;
use App\Models\EventCreditsLedger; use App\Models\EventCreditsLedger;
use App\Models\EventPurchase; use App\Models\EventPurchase;
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CreditController extends Controller class CreditController extends Controller
{ {
public function balance(Request $request) public function balance(Request $request): JsonResponse
{ {
$user = $request->user(); $tenant = $this->resolveTenant($request);
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
return response()->json([ return response()->json([
'balance' => $tenant->event_credits_balance, 'balance' => $tenant->event_credits_balance,
@@ -29,14 +26,10 @@ class CreditController extends Controller
public function ledger(Request $request) public function ledger(Request $request)
{ {
$user = $request->user(); $tenant = $this->resolveTenant($request);
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
$ledgers = EventCreditsLedger::where('tenant_id', $tenant->id) $ledgers = EventCreditsLedger::where('tenant_id', $tenant->id)
->orderBy('created_at', 'desc') ->orderByDesc('created_at')
->paginate(20); ->paginate(20);
return CreditLedgerResource::collection($ledgers); return CreditLedgerResource::collection($ledgers);
@@ -44,16 +37,100 @@ class CreditController extends Controller
public function history(Request $request) public function history(Request $request)
{ {
$user = $request->user(); $tenant = $this->resolveTenant($request);
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
$purchases = EventPurchase::where('tenant_id', $tenant->id) $purchases = EventPurchase::where('tenant_id', $tenant->id)
->orderBy('purchased_at', 'desc') ->orderByDesc('purchased_at')
->paginate(20); ->paginate(20);
return EventPurchaseResource::collection($purchases); return EventPurchaseResource::collection($purchases);
} }
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);
}
} }

View File

@@ -2,23 +2,18 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Tenant;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use App\Models\Tenant;
use App\Models\Event;
class CreditCheckMiddleware class CreditCheckMiddleware
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) { if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) {
$user = $request->user(); $tenant = $this->resolveTenant($request);
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
if ($tenant->event_credits_balance < 1) { if ($tenant->event_credits_balance < 1) {
return response()->json(['message' => 'Insufficient event credits'], 422); return response()->json(['message' => 'Insufficient event credits'], 422);
@@ -30,10 +25,28 @@ class CreditCheckMiddleware
$event = Event::where('slug', $eventSlug)->firstOrFail(); $event = Event::where('slug', $eventSlug)->firstOrFail();
$tenant = $event->tenant; $tenant = $event->tenant;
// For update, no credit check needed (already consumed on create)
$request->merge(['tenant' => $tenant]); $request->merge(['tenant' => $tenant]);
} }
return $next($request); return $next($request);
} }
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);
}
} }

View File

@@ -27,7 +27,11 @@ class TenantIsolation
} }
// Set tenant context for query scoping // 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 // Add tenant context to request for easy access in controllers
$request->attributes->set('current_tenant_id', $tenantId); $request->attributes->set('current_tenant_id', $tenantId);

View File

@@ -2,14 +2,16 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Models\TenantToken; use App\Models\TenantToken;
use Closure; use Closure;
use Firebase\JWT\Exceptions\TokenExpiredException;
use Firebase\JWT\JWT; use Firebase\JWT\JWT;
use Firebase\JWT\Key; use Firebase\JWT\Key;
use Illuminate\Auth\GenericUser;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException; use Illuminate\Support\Str;
class TenantTokenGuard class TenantTokenGuard
{ {
@@ -20,7 +22,7 @@ class TenantTokenGuard
{ {
$token = $this->getTokenFromRequest($request); $token = $this->getTokenFromRequest($request);
if (!$token) { if (! $token) {
return response()->json(['error' => 'Token not provided'], 401); return response()->json(['error' => 'Token not provided'], 401);
} }
@@ -30,25 +32,46 @@ class TenantTokenGuard
return response()->json(['error' => 'Invalid token'], 401); return response()->json(['error' => 'Invalid token'], 401);
} }
// Check token blacklist
if ($this->isTokenBlacklisted($decoded)) { if ($this->isTokenBlacklisted($decoded)) {
return response()->json(['error' => 'Token has been revoked'], 401); 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); return response()->json(['error' => 'Insufficient scopes'], 403);
} }
// Check expiration if (($decoded['exp'] ?? 0) < time()) {
if ($decoded['exp'] < time()) {
// Add to blacklist on expiry
$this->blacklistToken($decoded); $this->blacklistToken($decoded);
return response()->json(['error' => 'Token expired'], 401); return response()->json(['error' => 'Token expired'], 401);
} }
// Set tenant ID on request $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
$request->merge(['tenant_id' => $decoded['sub']]); 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); $request->attributes->set('decoded_token', $decoded);
return $next($request); return $next($request);
@@ -61,7 +84,7 @@ class TenantTokenGuard
{ {
$header = $request->header('Authorization'); $header = $request->header('Authorization');
if (str_starts_with($header, 'Bearer ')) { if (is_string($header) && str_starts_with($header, 'Bearer ')) {
return substr($header, 7); return substr($header, 7);
} }
@@ -78,11 +101,12 @@ class TenantTokenGuard
private function decodeToken(string $token): array private function decodeToken(string $token): array
{ {
$publicKey = file_get_contents(storage_path('app/public.key')); $publicKey = file_get_contents(storage_path('app/public.key'));
if (!$publicKey) { if (! $publicKey) {
throw new \Exception('JWT public key not found'); throw new \Exception('JWT public key not found');
} }
$decoded = JWT::decode($token, new Key($publicKey, 'RS256')); $decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
return (array) $decoded; return (array) $decoded;
} }
@@ -91,23 +115,29 @@ class TenantTokenGuard
*/ */
private function isTokenBlacklisted(array $decoded): bool private function isTokenBlacklisted(array $decoded): bool
{ {
$jti = isset($decoded['jti']) ? $decoded['jti'] : md5($decoded['sub'] . $decoded['iat']); $jti = $decoded['jti'] ?? null;
$cacheKey = "blacklisted_token:{$jti}"; if (! $jti) {
return false;
}
// Check cache first (faster) $cacheKey = "blacklisted_token:{$jti}";
if (Cache::has($cacheKey)) { if (Cache::has($cacheKey)) {
return true; return true;
} }
// Check DB blacklist $tokenRecord = TenantToken::query()->where('jti', $jti)->first();
$dbJti = $decoded['jti'] ?? null; if (! $tokenRecord) {
$blacklisted = TenantToken::where('jti', $dbJti) return false;
->orWhere('token_hash', md5($decoded['sub'] . $decoded['iat'])) }
->where('expires_at', '>', now())
->exists();
if ($blacklisted) { if ($tokenRecord->revoked_at) {
Cache::put($cacheKey, true, now()->addMinutes(5)); 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; return true;
} }
@@ -119,25 +149,35 @@ class TenantTokenGuard
*/ */
private function blacklistToken(array $decoded): void 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}"; $cacheKey = "blacklisted_token:{$jti}";
// Cache for immediate effect Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
Cache::put($cacheKey, true, $decoded['exp'] - time());
// Store in DB for persistence $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
TenantToken::updateOrCreate( $tokenType = $decoded['type'] ?? 'access';
[
'jti' => $jti, $record = TenantToken::query()->where('jti', $jti)->first();
'tenant_id' => $decoded['sub'], if ($record) {
], $record->update([
[ 'revoked_at' => now(),
'token_hash' => md5(json_encode($decoded)), 'expires_at' => $record->expires_at ?? now(),
'ip_address' => request()->ip(), ]);
'user_agent' => request()->userAgent(), return;
'expires_at' => now()->addHours(24), // Keep for 24h after expiry }
]
); 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 private function hasScopes(array $decoded, array $requiredScopes): bool
{ {
$tokenScopes = $decoded['scopes'] ?? []; $tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []);
if (!is_array($tokenScopes)) {
$tokenScopes = explode(' ', $tokenScopes);
}
foreach ($requiredScopes as $scope) { foreach ($requiredScopes as $scope) {
if (!in_array($scope, $tokenScopes)) { if (! in_array($scope, $tokenScopes, true)) {
return false; return false;
} }
} }
return true; return true;
} }
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;
}
} }

View File

@@ -14,13 +14,18 @@ class OAuthClient extends Model
'id', 'id',
'client_id', 'client_id',
'client_secret', 'client_secret',
'tenant_id',
'redirect_uris', 'redirect_uris',
'scopes', 'scopes',
'is_active',
]; ];
protected $casts = [ protected $casts = [
'id' => 'string',
'tenant_id' => 'integer',
'scopes' => 'array', 'scopes' => 'array',
'redirect_uris' => 'array', 'redirect_uris' => 'array',
'is_active' => 'bool',
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
]; ];

View File

@@ -9,6 +9,8 @@ class OAuthCode extends Model
{ {
protected $table = 'oauth_codes'; protected $table = 'oauth_codes';
public $timestamps = false;
protected $guarded = []; protected $guarded = [];
protected $fillable = [ protected $fillable = [

View File

@@ -7,6 +7,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RefreshToken extends Model class RefreshToken extends Model
{ {
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $table = 'refresh_tokens'; protected $table = 'refresh_tokens';
protected $guarded = []; protected $guarded = [];
@@ -14,6 +18,7 @@ class RefreshToken extends Model
protected $fillable = [ protected $fillable = [
'id', 'id',
'tenant_id', 'tenant_id',
'client_id',
'token', 'token',
'access_token', 'access_token',
'expires_at', 'expires_at',

View File

@@ -6,6 +6,10 @@ use Illuminate\Database\Eloquent\Model;
class TenantToken extends Model class TenantToken extends Model
{ {
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $table = 'tenant_tokens'; protected $table = 'tenant_tokens';
protected $guarded = []; protected $guarded = [];

View File

@@ -1,8 +1,11 @@
<?php <?php
use App\Http\Middleware\CreditCheckMiddleware;
use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\SetLocaleFromUser; use App\Http\Middleware\SetLocaleFromUser;
use App\Http\Middleware\TenantIsolation;
use App\Http\Middleware\TenantTokenGuard;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -16,6 +19,12 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->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->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [ $middleware->web(append: [

View File

@@ -12,9 +12,11 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('photos', function (Blueprint $table) { Schema::table('photos', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('event_id'); if (!Schema::hasColumn('photos', 'tenant_id')) {
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); $table->unsignedBigInteger('tenant_id')->nullable()->after('event_id');
$table->index('tenant_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 public function down(): void
{ {
Schema::table('photos', function (Blueprint $table) { Schema::table('photos', function (Blueprint $table) {
$table->dropForeign(['tenant_id']); if (Schema::hasColumn('photos', 'tenant_id')) {
$table->dropColumn('tenant_id'); $table->dropForeign(['tenant_id']);
$table->dropColumn('tenant_id');
}
}); });
} }
}; };

View File

@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('oauth_clients', 'is_active')) {
Schema::table('oauth_clients', function (Blueprint $table) {
$table->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);
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_clients', function (Blueprint $table) {
if (!Schema::hasColumn('oauth_clients', 'tenant_id')) {
$table->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');
}
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('refresh_tokens', function (Blueprint $table) {
if (!Schema::hasColumn('refresh_tokens', 'client_id')) {
$table->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');
}
});
}
};

View File

@@ -1,9 +1,7 @@
<?php <?php
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
@@ -31,6 +29,14 @@ class DatabaseSeeder extends Seeder
$this->call([ $this->call([
SuperAdminSeeder::class, SuperAdminSeeder::class,
DemoEventSeeder::class, DemoEventSeeder::class,
OAuthClientSeeder::class,
]); ]);
if (app()->environment(['local', 'development', 'demo'])) {
$this->call([
DemoPhotosSeeder::class,
DemoAchievementsSeeder::class,
]);
}
} }
} }

View File

@@ -0,0 +1,117 @@
<?php
namespace Database\Seeders;
use App\Models\Emotion;
use App\Models\Event;
use App\Models\Photo;
use App\Models\PhotoLike;
use App\Models\Task;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class DemoAchievementsSeeder extends Seeder
{
public function run(): void
{
$event = Event::where('slug', 'demo-wedding-2025')->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.');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Database\Seeders;
use App\Models\OAuthClient;
use App\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
class OAuthClientSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$clientId = 'tenant-admin-app';
$tenantId = Tenant::where('slug', 'demo')->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();
}
}

View File

@@ -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. 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. - Prefer modal routes (photo detail) layered over the gallery.
Route Map (proposed) Route Map (proposed)
- `/` Landing (QR/PIN input; deep-link handler) - `/` — Landing (QR/PIN input; deep-link handler)
- `/setup` Profile Setup (name/avatar; skippable) - `/setup` — Profile Setup (name/avatar; skippable)
- `/e/:slug` Home/Feed (default gallery view + info bar) - `/e/:slug` — Home/Feed (default gallery view + info bar)
- `/e/:slug/tasks` Task Picker (random/emotion) - `/e/:slug/tasks` — Task Picker (random/emotion)
- `/e/:slug/tasks/:taskId` Task Detail (card) - `/e/:slug/tasks/:taskId` — Task Detail (card)
- `/e/:slug/upload` Upload Picker (camera/library + tagging) - `/e/:slug/upload` — Upload Picker (camera/library + tagging)
- `/e/:slug/queue` Upload Queue (progress/retry) - `/e/:slug/queue` — Upload Queue (progress/retry)
- `/e/:slug/gallery` Gallery index (alias of Home or dedicated page) - `/e/:slug/gallery` — Gallery index (alias of Home or dedicated page)
- `/e/:slug/photo/:photoId` Photo Lightbox (modal over gallery) - `/e/:slug/photo/:photoId` — Photo Lightbox (modal over gallery)
- `/e/:slug/achievements` Achievements (optional) - `/e/:slug/achievements` — Achievements (optional)
- `/e/:slug/slideshow` Slideshow (optional, read-only) - `/e/:slug/slideshow` — Slideshow (optional, read-only)
- `/settings` — Settings (language/theme/cache/legal) - `/legal/:page` — Legal pages (imprint/privacy/terms)
- `/legal/:page` — Legal pages (imprint/privacy/terms) - `*` — NotFound
- `*` — NotFound
Note: The settings experience is handled via the header sheet (no dedicated route; legal pages stay routable under /legal/:page).
Guards & Loaders Guards & Loaders
- `EventGuard` verifies event token in storage; attempts refresh; otherwise redirects to `/`. - `EventGuard` — verifies event token in storage; attempts refresh; otherwise redirects to `/`.
- `PrefetchEvent` loads event metadata/theme on `:slug` routes. - `PrefetchEvent` — loads event metadata/theme on `:slug` routes.
- `OfflineFallback` surfaces offline banner and queues mutations. - `OfflineFallback` — surfaces offline banner and queues mutations.
Suggested Directory Structure Suggested Directory Structure
``` ```
@@ -46,7 +47,7 @@ apps/guest-pwa/
PhotoLightbox.tsx // modal route PhotoLightbox.tsx // modal route
AchievementsPage.tsx AchievementsPage.tsx
SlideshowPage.tsx SlideshowPage.tsx
SettingsPage.tsx SettingsSheet.tsx
LegalPage.tsx LegalPage.tsx
NotFoundPage.tsx NotFoundPage.tsx
components/ components/
@@ -116,7 +117,7 @@ export const router = createBrowserRouter([
{ path: 'slideshow', element: <SlideshowPage /> }, { path: 'slideshow', element: <SlideshowPage /> },
], ],
}, },
{ path: '/settings', element: <SettingsPage /> }, // Settings sheet is rendered inside Header; no standalone route.
{ path: '/legal/:page', element: <LegalPage /> }, { path: '/legal/:page', element: <LegalPage /> },
{ path: '*', element: <NotFoundPage /> }, { path: '*', element: <NotFoundPage /> },
]); ]);
@@ -124,11 +125,13 @@ export const router = createBrowserRouter([
Component Checklist Component Checklist
- Layout - 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 - Entry
- `QRPinForm` (QR deep link or PIN fallback), `ProfileForm` (name/avatar). - `QRPinForm` (QR deep link or PIN fallback), `ProfileForm` (name/avatar).
- Home/Feed - 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 - Tasks
- `EmotionPickerGrid`, `TaskCard` (shows duration, group size, actions). - `EmotionPickerGrid`, `TaskCard` (shows duration, group size, actions).
- Capture/Upload (photos only) - Capture/Upload (photos only)
@@ -136,7 +139,7 @@ Component Checklist
- Photo View - Photo View
- `PhotoLightbox` (modal), like/share controls, emotion tags. - `PhotoLightbox` (modal), like/share controls, emotion tags.
- Settings & Legal - Settings & Legal
- `SettingsPage` sections, `LegalPage` renderer. - `SettingsSheet` (Header-Overlay mit Namenseditor, eingebetteten Rechtsdokumenten, Cache-Leeren), `LegalPage` Renderer.
State & Data State & Data
- TanStack Query for server data (events, photos); optimistic updates for likes. - TanStack Query for server data (events, photos); optimistic updates for likes.

View File

@@ -1,4 +1,4 @@
# 07 Guest PWA # 07 — Guest PWA
Goal Goal
- Delight guests with a frictionless, installable photo experience that works offline, respects privacy, and requires no account. - 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. - No comments or chat. No facial recognition. No public profiles. No videos.
Personas Personas
- Guest (attendee) scans QR, uploads photos, browses and likes. - Guest (attendee) — scans QR, uploads photos, browses and likes.
- Host (tenant) optionally shares event PIN with guests; moderates via Tenant Admin PWA. - Host (tenant) — optionally shares event PIN with guests; moderates via Tenant Admin PWA.
Top Journeys Top Journeys
- Join: Scan QR Open event Accept terms Optional PIN Land on Gallery. - Join: Scan QR → Open event → Accept terms → Optional PIN → Land on Gallery.
- Upload: Add photos Review Submit Background upload Success/Retry. - Upload: Add photos → Review → Submit → Background upload → Success/Retry.
- Browse: Infinite gallery Filter (emotion/task) Open photo Like/Share Back. - Browse: Infinite gallery → Filter (emotion/task) → Open photo → Like/Share → Back.
Core Features Core Features
- Event access - Event access
@@ -25,14 +25,14 @@ Core Features
- Capture & upload - Capture & upload
- Choose from camera or library; limit file size; show remaining upload cap. - 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. - 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 - Gallery
- Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe. - Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe.
- Like (heart) with optimistic UI; share system sheet (URL to CDN variant). - 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). - Filters: emotion, featured, mine (local-only tag for items uploaded from this device).
- Safety & abuse controls - Safety & abuse controls
- Rate limits per device and IP; content-length checks; mime/type sniffing. - 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 - Privacy & legal
- First run shows legal links (imprint/privacy); consent for push if enabled. - 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. - 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 Picker: camera/library, selection preview, emotion/task tagging.
- Upload Queue: items with progress, retry, remove; background sync toggle. - Upload Queue: items with progress, retry, remove; background sync toggle.
- Photo Lightbox: zoom, like, share; show emotion tags. - 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 Wireframes
- See wireframes file at docs/wireframes/guest-pwa.md for low-fidelity layouts and flows. - 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. - 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). - States: invalid/expired token, event closed, offline (allow PIN entry and queue attempt).
- Profil-Setup (Name/Avatar) - Profil-Setup (Name/Avatar)
- Purpose: Optional personalization for likes/attribution. - Purpose: Optional personalisation fuer Likes/Statistiken; Name wird lokal im Browser gespeichert.
- UI: Name text field, optional avatar picker; one-time before first entry to event. - UI: Name-Feld mit Sofort-Validierung; Avatar folgt spaeter.
- Behavior: Skippable; editable later in Settings. - Behavior: Nicht verpflichtend, aber empfohlen; Name kann jederzeit im Settings Sheet angepasst oder geloescht werden.
- Startseite (Home/Feed) - Startseite (Home/Feed)
- Purpose: Central hub; show event title/subheadline and CTAs. - Purpose: Central hub; begruesst Gaeste mit ihrem hinterlegten Namen und fuehrt zu den wichtigsten Aktionen.
- Header: Event name + subheadline (e.g., “Dein Fotospiel zur Hochzeit”). - Header: Eventtitel plus Live-Kennzahlen (online Gaeste, geloeste Aufgaben); hero-card zeigt "Hey {Name}!".
- Info bar: “X Gäste online • Y Aufgaben gelöst”. - Highlights: Drei CTA-Karten fuer Aufgabe ziehen, Direkt-Upload und Galerie sowie ein Button fuer die Upload-Warteschlange.
- CTAs: „Aufgabe ziehen“ (random), „Wie fühlst du dich?“ (emotion picker), small link “Einfach ein Foto machen”. - Content: EmotionPicker und GalleryPreview bilden weiterhin den Einstieg in Spielstimmung und aktuelle Fotos.
- Content: Gallery/Feed with photos + likes.
- Aufgaben-Flow - Aufgaben-Flow
- Aufgaben-Picker: Choose random task or emotion mood. - 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. - Aufgaben-Detail (Karte): Task text, emoji tag, estimated duration, suggested group size; actions: take photo, new task (same mood), change mood.
- Foto-Interaktion - Foto-Interaktion
- Kamera/Upload: Capture or pick; progress + success message on completion; background sync. - 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. - Foto-Detailansicht: Fullscreen; likes/reactions; linked task + (optional) uploader name.
- Motivation & Spiel - Motivation & Spiel
- Achievements/Erfolge: Badges (e.g., Erstes Foto, 5 Aufgaben, Beliebtestes Foto); personal progress. - Achievements/Erfolge: Badges (e.g., Erstes Foto, 5 Aufgaben, Beliebtestes Foto); personal progress.
- Optionale Ergänzungen - Optionale Ergänzungen
- Slideshow/Präsentationsmodus: Auto-rotating gallery for TV/Beamer with likes/task overlay. - Slideshow/Präsentationsmodus: Auto-rotating gallery for TV/Beamer with likes/task overlay.
- Onboarding: 12 “So funktioniert das Spiel hints the first time. - Onboarding: 1–2 “So funktioniert das Spiel” hints the first time.
- Event-Abschluss: Danke fürs Mitmachen, summary stats, link/QR to online gallery. - Event-Abschluss: “Danke fürs Mitmachen”, summary stats, link/QR to online gallery.
Technical Notes Technical Notes
- Installability: PWA manifest + service worker; prompt A2HS on supported browsers. - 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. - Background Sync: use Background Sync API when available; fallback to retry on app open.
- Accessibility: large tap targets, high contrast, keyboard support, reduced motion. - Accessibility: large tap targets, high contrast, keyboard support, reduced motion.
- i18n: default `de`, fallback `en`; all strings in locale files; RTL not in MVP. - 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. - Realtime model: periodic polling (no WebSockets). Home counters every 10s; gallery delta every 30s with exponential backoff when tab hidden or offline.
API Touchpoints API Touchpoints
- GET `/api/v1/events/{slug}` public event metadata (when open) + theme. - GET `/api/v1/events/{slug}` — public event metadata (when open) + theme.
- GET `/api/v1/events/{slug}/photos` paginated gallery (approved only). - GET `/api/v1/events/{slug}/photos` — paginated gallery (approved only).
- POST `/api/v1/events/{slug}/photos` signed upload initiation; returns URL + fields. - POST `/api/v1/events/{slug}/photos` — signed upload initiation; returns URL + fields.
- POST (S3) direct upload to object storage; then backend finalize call. - POST (S3) — direct upload to object storage; then backend finalize call.
- POST `/api/v1/photos/{id}/like` idempotent like with device token. - POST `/api/v1/photos/{id}/like` — idempotent like with device token.
Limits (MVP defaults) Limits (MVP defaults)
- Max uploads per device per event: 50 - Max uploads per device per event: 50
@@ -100,10 +99,10 @@ Limits (MVP defaults)
- Max resolution: 2560px longest edge per photo - Max resolution: 2560px longest edge per photo
Edge Cases Edge Cases
- Token expired/invalid Show Event closed/invalid link; link to retry. - Token expired/invalid → Show “Event closed/invalid link”; link to retry.
- No connectivity Queue actions; show badge; retry policy with backoff. - No connectivity → Queue actions; show badge; retry policy with backoff.
- Storage full Offer to clear cache or deselect files. - Storage full → Offer to clear cache or deselect files.
- Permission denied (camera/photos) Explain and offer system shortcut. - Permission denied (camera/photos) → Explain and offer system shortcut.
Decisions Decisions
- Videos are not supported (capture/upload strictly photos). - Videos are not supported (capture/upload strictly photos).

View File

@@ -7,7 +7,7 @@ This document outlines the authentication requirements and implementation detail
## Authentication Flow ## Authentication Flow
### 1. Authorization Request ### 1. Authorization Request
- **Endpoint**: `POST /oauth/authorize` - **Endpoint**: `GET /api/v1/oauth/authorize`
- **Method**: GET (redirect from frontend) - **Method**: GET (redirect from frontend)
- **Parameters**: - **Parameters**:
- `client_id`: Fixed client ID for tenant-admin-app (`tenant-admin-app`) - `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. **Response**: Redirect to frontend with authorization code and state parameters.
### 2. Token Exchange ### 2. Token Exchange
- **Endpoint**: `POST /oauth/token` - **Endpoint**: `POST /api/v1/oauth/token`
- **Method**: POST - **Method**: POST
- **Content-Type**: `application/x-www-form-urlencoded` - **Content-Type**: `application/x-www-form-urlencoded`
- **Parameters**: - **Parameters**:
@@ -44,7 +44,7 @@ This document outlines the authentication requirements and implementation detail
``` ```
### 3. Token Refresh ### 3. Token Refresh
- **Endpoint**: `POST /oauth/token` - **Endpoint**: `POST /api/v1/oauth/token`
- **Method**: POST - **Method**: POST
- **Content-Type**: `application/x-www-form-urlencoded` - **Content-Type**: `application/x-www-form-urlencoded`
- **Parameters**: - **Parameters**:
@@ -102,6 +102,19 @@ This document outlines the authentication requirements and implementation detail
### oauth_clients Table ### oauth_clients Table
```sql ```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 ( CREATE TABLE oauth_clients (
id VARCHAR(255) PRIMARY KEY, id VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255) UNIQUE NOT NULL, client_id VARCHAR(255) UNIQUE NOT NULL,
@@ -133,6 +146,20 @@ CREATE TABLE oauth_codes (
### refresh_tokens Table ### refresh_tokens Table
```sql ```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 ( CREATE TABLE refresh_tokens (
id VARCHAR(255) PRIMARY KEY, id VARCHAR(255) PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL, tenant_id VARCHAR(255) NOT NULL,
@@ -261,18 +288,22 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- Revoke old refresh token immediately - Revoke old refresh token immediately
- Limit refresh tokens per tenant to 5 active - 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 - Authorization requests: 10/minute per IP
- Token exchanges: 5/minute per IP - Token exchanges: 5/minute per IP
- Token validation: 100/minute per tenant - Token validation: 100/minute per tenant
### 4. Logging and Monitoring ### 5. Logging and Monitoring
- Log all authentication attempts (success/failure) - Log all authentication attempts (success/failure)
- Monitor token usage patterns - Monitor token usage patterns
- Alert on unusual activity (multiple failed attempts, token anomalies) - Alert on unusual activity (multiple failed attempts, token anomalies)
- Track refresh token usage for security analysis - Track refresh token usage for security analysis
### 5. Database Cleanup ### 6. Database Cleanup
- Cron job to remove expired authorization codes (daily) - Cron job to remove expired authorization codes (daily)
- Remove expired refresh tokens (weekly) - Remove expired refresh tokens (weekly)
- Clean blacklisted tokens after expiry (daily) - Clean blacklisted tokens after expiry (daily)
@@ -316,3 +347,9 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- Log all security-related events - 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. This implementation provides secure, scalable authentication for the Fotospiel tenant system, following OAuth2 best practices with PKCE for public clients.

View File

@@ -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. 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 - **Response**: Neuer Access/Refresh-Token
- **Token Validation**: `GET /api/v1/tenant/me` - **Token Validation**: `GET /api/v1/tenant/me`
- **Redirect URI**: Standardmaessig `${origin}/admin/auth/callback` (per Vite-Env anpassbar)
- **Headers**: `Authorization: Bearer {access_token}` - **Headers**: `Authorization: Bearer {access_token}`
- **Response**: `{ id, email, tenant_id, role, name }` - **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) - **Validierung**: Prüft Credit-Balance (1 Credit pro Event)
### Event-Details ### Event-Details
- **GET /api/v1/tenant/events/{id}** - **GET /api/v1/tenant/events/{slug}**
- **Headers**: `Authorization: Bearer {token}` - **Headers**: `Authorization: Bearer {token}`
- **Response**: Erweitertes Event mit `{ tasks[], members, stats { likes, views, uploads } }` - **Response**: Erweitertes Event mit `{ tasks[], members, stats { likes, views, uploads } }`
### Event updaten ### 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}` - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`, `If-Match: {etag}`
- **Body**: Partial Event-Daten (title, date, location, description) - **Body**: Partial Event-Daten (title, date, location, description)
- **Response**: Updated Event - **Response**: Updated Event
### Event archivieren ### Event archivieren
- **DELETE /api/v1/tenant/events/{id}** - **DELETE /api/v1/tenant/events/{slug}**
- **Headers**: `Authorization: Bearer {token}`, `If-Match: {etag}` - **Headers**: `Authorization: Bearer {token}`, `If-Match: {etag}`
- **Response**: 204 No Content (soft-delete) - **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 }` - **Response**: `{ balance: number }`
### Ledger-Verlauf ### Ledger-Verlauf
- **GET /api/v1/tenant/ledger** - **GET /api/v1/tenant/credits/ledger**
- **Headers**: `Authorization: Bearer {token}` - **Headers**: `Authorization: Bearer {token}`
- **Params**: `page`, `per_page` (Pagination) - **Params**: `page`, `per_page` (Pagination)
- **Response**: `{ data: LedgerEntry[], current_page, last_page }` - **Response**: `{ data: LedgerEntry[], current_page, last_page }`
- **LedgerEntry**: `{ id, type, amount, credits, date, description, transactionId? }` - **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 ### Kauf-Intent erstellen
- **POST /api/v1/tenant/purchases/intent** - **POST /api/v1/tenant/purchases/intent**
- **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json` - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
@@ -242,8 +257,9 @@ curl -H "Authorization: Bearer {token}" \
## Deployment ## Deployment
### Environment-Variablen ### Environment-Variablen
- **REACT_APP_API_URL**: Backend-API-URL (Pflicht) - **VITE_API_URL**: Backend-API-URL (Pflicht)
- **REACT_APP_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht) - **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht)
- **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat)
### Build & Deploy ### Build & Deploy
1. **Development**: `npm run dev` 1. **Development**: `npm run dev`
@@ -255,3 +271,4 @@ curl -H "Authorization: Bearer {token}" \
- Installierbar auf Desktop/Mobile via "Zum Startbildschirm hinzufügen". - Installierbar auf Desktop/Mobile via "Zum Startbildschirm hinzufügen".
Für weitere Details siehe die spezifischen Dokumentationsdateien. Für weitere Details siehe die spezifischen Dokumentationsdateien.

BIN
docs/screenshots/1start.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 KiB

BIN
docs/screenshots/2start.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -12,13 +12,15 @@ flowchart TD
E --> F[Upload Queue] E --> F[Upload Queue]
D --> G[Photo Lightbox] D --> G[Photo Lightbox]
D --> H[Filters] D --> H[Filters]
D --> I[Settings] D --> I[Settings Sheet]
F --> D F --> D
G --> D G --> D
``` ```
Gallery (mobile) 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] | | Toolbar: [Scan?] [Filter] [Upload] [Menu] |

84
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "html", "name": "fotospiel-app",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0", "@inertiajs/react": "^2.1.0",
"@playwright/mcp": "^0.0.37",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3",
@@ -28,6 +29,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"globals": "^15.14.0", "globals": "^15.14.0",
"html5-qrcode": "^2.3.8",
"laravel-vite-plugin": "^2.0", "laravel-vite-plugin": "^2.0",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"playwright": "^1.55.0", "playwright": "^1.55.0",
@@ -43,6 +45,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3", "@laravel/vite-plugin-wayfinder": "^0.1.3",
"@playwright/test": "^1.55.0",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@@ -1773,6 +1776,78 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -5991,6 +6066,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.3.1.tgz",
"integrity": "sha512-sgai5gahy/TiyTiqJEwIFpAuPhmkpt7sGVdRfcmNH53Yc3yI57+zFVmIaqbTST0jP/7tSqZuI0aSllXL2HIw5w==", "integrity": "sha512-sgai5gahy/TiyTiqJEwIFpAuPhmkpt7sGVdRfcmNH53Yc3yI57+zFVmIaqbTST0jP/7tSqZuI0aSllXL2HIw5w==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@antfu/ni": "^25.0.0", "@antfu/ni": "^25.0.0",
"@babel/core": "^7.28.0", "@babel/core": "^7.28.0",

View File

@@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3", "@laravel/vite-plugin-wayfinder": "^0.1.3",
"@playwright/test": "^1.55.0",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@@ -27,6 +28,7 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0", "@inertiajs/react": "^2.1.0",
"@playwright/mcp": "^0.0.37",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3",
@@ -48,6 +50,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"globals": "^15.14.0", "globals": "^15.14.0",
"html5-qrcode": "^2.3.8",
"laravel-vite-plugin": "^2.0", "laravel-vite-plugin": "^2.0",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"playwright": "^1.55.0", "playwright": "^1.55.0",

View File

@@ -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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because one or more lines are too long

32
playwright.config.ts Normal file
View File

@@ -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'] },
},
],
});

View File

@@ -7,25 +7,52 @@ self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim()); event.waitUntil(self.clients.claim());
}); });
const ASSETS_CACHE = 'guest-assets-v1';
const IMAGES_CACHE = 'guest-images-v1';
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const req = event.request; const req = event.request;
if (req.method !== 'GET' || !req.url.startsWith(self.location.origin)) return; if (req.method !== 'GET') return;
event.respondWith((async () => {
const cache = await caches.open('guest-runtime'); const url = new URL(req.url);
const cached = await cache.match(req); // Only handle same-origin requests
if (cached) return cached; if (url.origin !== self.location.origin) return;
try {
const res = await fetch(req); // Never cache API calls; let them hit network directly
// Cache static assets and images if (url.pathname.startsWith('/api/')) return;
const ct = res.headers.get('content-type') || '';
if (res.ok && (ct.includes('text/css') || ct.includes('javascript') || ct.startsWith('image/'))) { // Cache-first for images
cache.put(req, res.clone()); 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;
return cached || new Response('Offline', { status: 503 }); }
}
})()); // 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) => { self.addEventListener('sync', (event) => {

0
redirect_uris Normal file
View File

View File

@@ -1,98 +1,103 @@
export async function login(email: string, password: string): Promise<{ token: string }> { import { authorizedFetch } from './auth/tokens';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const res = await fetch('/api/v1/tenant/login', { type JsonValue = Record<string, any>;
method: 'POST',
headers: { async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
'Content-Type': 'application/json', if (!response.ok) {
'X-CSRF-TOKEN': csrfToken || '', const body = await safeJson(response);
}, console.error('[API]', message, response.status, body);
body: JSON.stringify({ email, password }), throw new Error(message);
}); }
if (!res.ok) throw new Error('Login failed'); return (await response.json()) as T;
return res.json(); }
async function safeJson(response: Response): Promise<JsonValue | null> {
try {
return (await response.clone().json()) as JsonValue;
} catch {
return null;
}
} }
export async function getEvents(): Promise<any[]> { export async function getEvents(): Promise<any[]> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch('/api/v1/tenant/events');
const res = await fetch('/api/v1/tenant/events', { const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load events');
headers: { Authorization: `Bearer ${token}` }, return data.data ?? [];
});
if (!res.ok) throw new Error('Failed to load events');
const json = await res.json();
return json.data ?? [];
} }
export async function createEvent(payload: { name: string; slug: string; date?: string; is_active?: boolean }): Promise<number> { export async function createEvent(payload: { name: string; slug: string; date?: string; is_active?: boolean }): Promise<number> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch('/api/v1/tenant/events', {
const res = await fetch('/api/v1/tenant/events', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error('Failed to create event'); const data = await jsonOrThrow<{ id: number }>(response, 'Failed to create event');
const json = await res.json(); return data.id;
return json.id;
} }
export async function updateEvent(id: number, payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>): Promise<void> { export async function updateEvent(
const token = localStorage.getItem('ta_token') || ''; id: number,
const res = await fetch(`/api/v1/tenant/events/${id}`, { payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>
): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), 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<any[]> { export async function getEventPhotos(id: number): Promise<any[]> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch(`/api/v1/tenant/events/${id}/photos`);
const res = await fetch(`/api/v1/tenant/events/${id}/photos`, { headers: { Authorization: `Bearer ${token}` } }); const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load photos');
if (!res.ok) throw new Error('Failed to load photos'); return data.data ?? [];
const json = await res.json();
return json.data ?? [];
} }
export async function featurePhoto(id: number) { export async function featurePhoto(id: number): Promise<void> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST' });
await fetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) {
await safeJson(response);
throw new Error('Failed to feature photo');
}
} }
export async function unfeaturePhoto(id: number) { export async function unfeaturePhoto(id: number): Promise<void> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST' });
await fetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) {
await safeJson(response);
throw new Error('Failed to unfeature photo');
}
} }
export async function getEvent(id: number): Promise<any> { export async function getEvent(id: number): Promise<any> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch(`/api/v1/tenant/events/${id}`);
const res = await fetch(`/api/v1/tenant/events/${id}`, { headers: { Authorization: `Bearer ${token}` } }); return jsonOrThrow<any>(response, 'Failed to load event');
if (!res.ok) throw new Error('Failed to load event');
return res.json();
} }
export async function toggleEvent(id: number): Promise<boolean> { export async function toggleEvent(id: number): Promise<boolean> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST' });
const res = await fetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); const data = await jsonOrThrow<{ is_active: boolean }>(response, 'Failed to toggle event');
if (!res.ok) throw new Error('Failed to toggle'); return !!data.is_active;
const json = await res.json();
return !!json.is_active;
} }
export async function getEventStats(id: number): Promise<{ total: number; featured: number; likes: number }> { export async function getEventStats(id: number): Promise<{ total: number; featured: number; likes: number }> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch(`/api/v1/tenant/events/${id}/stats`);
const res = await fetch(`/api/v1/tenant/events/${id}/stats`, { headers: { Authorization: `Bearer ${token}` } }); return jsonOrThrow<{ total: number; featured: number; likes: number }>(response, 'Failed to load stats');
if (!res.ok) throw new Error('Failed to load stats');
return res.json();
} }
export async function createInviteLink(id: number): Promise<string> { export async function createInviteLink(id: number): Promise<string> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST' });
const res = await fetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); const data = await jsonOrThrow<{ link: string }>(response, 'Failed to create invite');
if (!res.ok) throw new Error('Failed to create invite'); return data.link;
const json = await res.json();
return json.link as string;
} }
export async function deletePhoto(id: number) { export async function deletePhoto(id: number): Promise<void> {
const token = localStorage.getItem('ta_token') || ''; const response = await authorizedFetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE' });
await fetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) {
await safeJson(response);
throw new Error('Failed to delete photo');
}
} }

View File

@@ -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<string | null>;
refreshProfile: () => Promise<void>;
}
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [status, setStatus] = React.useState<AuthStatus>('loading');
const [user, setUser] = React.useState<TenantProfile | null>(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<AuthContextValue>(
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
[status, user, login, logout, completeLogin, refreshProfile]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export function useAuth(): AuthContextValue {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -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<string> {
const data = new TextEncoder().encode(verifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(digest));
}

View File

@@ -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<AuthFailureHandler>();
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<StoredTokens>(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<string> {
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<string> {
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<Response> {
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<void> {
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<string | null> {
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);
}

View File

@@ -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<T>(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;
}
}

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
import { AuthProvider } from './auth/context';
import { router } from './router'; import { router } from './router';
import '../../css/app.css'; import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance'; import { initializeTheme } from '@/hooks/use-appearance';
@@ -9,7 +10,8 @@ initializeTheme();
const rootEl = document.getElementById('root')!; const rootEl = document.getElementById('root')!;
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -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<string | null>(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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet ...</span>
{error && <div className="max-w-sm rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React from 'react'; 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 { 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() { export default function EventDetailPage() {
const [sp] = useSearchParams(); 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 [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null);
const [invite, setInvite] = React.useState<string | null>(null); const [invite, setInvite] = React.useState<string | null>(null);
async function load() { const load = React.useCallback(async () => {
const e = await getEvent(id); try {
setEv(e); const event = await getEvent(id);
setStats(await getEventStats(id)); setEv(event);
} setStats(await getEventStats(id));
React.useEffect(() => { load(); }, [id]); } catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}, [id]);
React.useEffect(() => {
load();
}, [load]);
async function onToggle() { async function onToggle() {
const isActive = await toggleEvent(id); try {
setEv((o: any) => ({ ...(o || {}), is_active: isActive })); const isActive = await toggleEvent(id);
setEv((previous: any) => ({ ...(previous || {}), is_active: isActive }));
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
} }
async function onInvite() { async function onInvite() {
const link = await createInviteLink(id); try {
setInvite(link); const link = await createInviteLink(id);
try { await navigator.clipboard.writeText(link); } catch {} setInvite(link);
try {
await navigator.clipboard.writeText(link);
} catch {
// clipboard may be unavailable
}
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
} }
if (!ev) return <div className="p-4">Lade</div>; if (!ev) {
const joinLink = `${location.origin}/e/${ev.slug}`; return <div className="p-4">Lade ...</div>;
}
const joinLink = `${window.location.origin}/e/${ev.slug}`;
const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`; const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`;
return ( return (
<div className="mx-auto max-w-3xl p-4 space-y-4"> <div className="mx-auto max-w-3xl space-y-4 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Event: {renderName(ev.name)}</h1> <h1 className="text-lg font-semibold">Event: {renderName(ev.name)}</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="secondary" onClick={onToggle}>{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}</Button> <Button variant="secondary" onClick={onToggle}>
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>Fotos moderieren</Button> {ev.is_active ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>
Fotos moderieren
</Button>
</div> </div>
</div> </div>
<div className="rounded border p-3 text-sm"> <div className="rounded border p-3 text-sm">
@@ -47,31 +80,45 @@ export default function EventDetailPage() {
<div>Datum: {ev.date ?? '-'}</div> <div>Datum: {ev.date ?? '-'}</div>
<div>Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}</div> <div>Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}</div>
</div> </div>
<div className="grid grid-cols-3 gap-3 text-center text-sm"> <div className="grid grid-cols-1 gap-3 text-center text-sm sm:grid-cols-3">
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.total ?? 0}</div><div>Fotos</div></div> <StatCard label="Fotos" value={stats?.total ?? 0} />
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.featured ?? 0}</div><div>Featured</div></div> <StatCard label="Featured" value={stats?.featured ?? 0} />
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.likes ?? 0}</div><div>Likes gesamt</div></div> <StatCard label="Likes gesamt" value={stats?.likes ?? 0} />
</div> </div>
<div className="rounded border p-3"> <div className="rounded border p-3 text-sm">
<div className="mb-2 text-sm font-medium">Join-Link</div> <div className="mb-2 font-medium">Join-Link</div>
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<input className="w-full rounded border p-2 text-sm" value={joinLink} readOnly /> <input className="w-full rounded border p-2 text-sm" value={joinLink} readOnly />
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>Kopieren</Button> <Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>
Kopieren
</Button>
</div> </div>
<div className="mb-2 text-sm font-medium">QR</div> <div className="mb-2 font-medium">QR</div>
<img src={qrUrl} alt="QR" width={200} height={200} className="rounded border" /> <img src={qrUrl} alt="QR" width={200} height={200} className="rounded border" />
<div className="mt-3"> <div className="mt-3">
<Button variant="secondary" onClick={onInvite}>Einladungslink erzeugen</Button> <Button variant="secondary" onClick={onInvite}>
{invite && <div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>} Einladungslink erzeugen
</Button>
{invite && (
<div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>
)}
</div> </div>
</div> </div>
</div> </div>
); );
} }
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="rounded border p-3">
<div className="text-2xl font-semibold">{value}</div>
<div>{label}</div>
</div>
);
}
function renderName(name: any): string { function renderName(name: any): string {
if (typeof name === 'string') return name; if (typeof name === 'string') return name;
if (name && (name.de || name.en)) return name.de || name.en; if (name && (name.de || name.en)) return name.de || name.en;
return JSON.stringify(name); return JSON.stringify(name);
} }

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { createEvent, updateEvent } from '../api'; import { createEvent, updateEvent } from '../api';
import { isAuthError } from '../auth/tokens';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
export default function EventFormPage() { export default function EventFormPage() {
@@ -13,10 +14,12 @@ export default function EventFormPage() {
const [date, setDate] = React.useState(''); const [date, setDate] = React.useState('');
const [active, setActive] = React.useState(true); const [active, setActive] = React.useState(true);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const isEdit = !!id; const isEdit = !!id;
async function save() { async function save() {
setSaving(true); setSaving(true);
setError(null);
try { try {
if (isEdit) { if (isEdit) {
await updateEvent(Number(id), { name, slug, date, is_active: active }); 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 }); await createEvent({ name, slug, date, is_active: active });
} }
nav('/admin/events'); nav('/admin/events');
} finally { setSaving(false); } } catch (e) {
if (!isAuthError(e)) {
setError('Speichern fehlgeschlagen');
}
} finally {
setSaving(false);
}
} }
return ( return (
<div className="mx-auto max-w-md p-4 space-y-3"> <div className="mx-auto max-w-md space-y-3 p-4">
<h1 className="text-lg font-semibold">{isEdit ? 'Event bearbeiten' : 'Neues Event'}</h1> <h1 className="text-lg font-semibold">{isEdit ? 'Event bearbeiten' : 'Neues Event'}</h1>
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} /> <Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Input placeholder="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} /> <Input placeholder="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} />
<Input placeholder="Datum" type="date" value={date} onChange={(e) => setDate(e.target.value)} /> <Input placeholder="Datum" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv</label> <label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv
</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={save} disabled={saving || !name || !slug}>{saving ? 'Speichern…' : 'Speichern'}</Button> <Button onClick={save} disabled={saving || !name || !slug}>
{saving ? 'Speichern <20>' : 'Speichern'}
</Button>
<Button variant="secondary" onClick={() => nav(-1)}>Abbrechen</Button> <Button variant="secondary" onClick={() => nav(-1)}>Abbrechen</Button>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventPhotosPage() { export default function EventPhotosPage() {
const [sp] = useSearchParams(); const [sp] = useSearchParams();
@@ -9,33 +10,83 @@ export default function EventPhotosPage() {
const [rows, setRows] = React.useState<any[]>([]); const [rows, setRows] = React.useState<any[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
async function load() { const load = React.useCallback(async () => {
setLoading(true); setLoading(true);
try { setRows(await getEventPhotos(id)); } finally { setLoading(false); } try {
} setRows(await getEventPhotos(id));
React.useEffect(() => { load(); }, [id]); } catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
} finally {
setLoading(false);
}
}, [id]);
async function onFeature(p: any) { await featurePhoto(p.id); load(); } React.useEffect(() => {
async function onUnfeature(p: any) { await unfeaturePhoto(p.id); load(); } load();
async function onDelete(p: any) { await deletePhoto(p.id); 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 ( return (
<div className="mx-auto max-w-5xl p-4"> <div className="mx-auto max-w-5xl p-4">
<h1 className="mb-3 text-lg font-semibold">Fotos moderieren</h1> <h1 className="mb-3 text-lg font-semibold">Fotos moderieren</h1>
{loading && <div>Lade</div>} {loading && <div>Lade ...</div>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{rows.map((p) => ( {rows.map((p) => (
<div key={p.id} className="rounded border p-2"> <div key={p.id} className="rounded border p-2">
<img src={p.thumbnail_path || p.file_path} className="mb-2 aspect-square w-full rounded object-cover" /> <img
src={p.thumbnail_path || p.file_path}
className="mb-2 aspect-square w-full rounded object-cover"
alt={p.caption ?? 'Foto'}
/>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span> {p.likes_count}</span> <span>?? {p.likes_count}</span>
<div className="flex gap-1"> <div className="flex gap-1">
{p.is_featured ? ( {p.is_featured ? (
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>Unfeature</Button> <Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>
Unfeature
</Button>
) : ( ) : (
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>Feature</Button> <Button size="sm" variant="secondary" onClick={() => onFeature(p)}>
Feature
</Button>
)} )}
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>Löschen</Button> <Button size="sm" variant="destructive" onClick={() => onDelete(p)}>
L<EFBFBD>schen
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -44,4 +95,3 @@ export default function EventPhotosPage() {
</div> </div>
); );
} }

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { getEvents } from '../api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { getEvents } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventsPage() { export default function EventsPage() {
const [rows, setRows] = React.useState<any[]>([]); const [rows, setRows] = React.useState<any[]>([]);
@@ -11,7 +12,15 @@ export default function EventsPage() {
React.useEffect(() => { React.useEffect(() => {
(async () => { (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() {
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">Meine Events</h1> <h1 className="text-lg font-semibold">Meine Events</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>Neues Event</Button> <Button variant="secondary" onClick={() => nav('/admin/events/new')}>
<Link to="/admin/settings"><Button variant="secondary">Einstellungen</Button></Link> Neues Event
</Button>
<Link to="/admin/settings">
<Button variant="secondary">Einstellungen</Button>
</Link>
</div> </div>
</div> </div>
{loading && <div>Lade</div>} {loading && <div>Lade ...</div>}
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>} {error && (
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>
)}
<div className="divide-y rounded border"> <div className="divide-y rounded border">
{rows.map((e) => ( {rows.map((event) => (
<div key={e.id} className="flex items-center justify-between p-3"> <div key={event.id} className="flex items-center justify-between p-3">
<div className="text-sm"> <div className="text-sm">
<div className="font-medium">{renderName(e.name)}</div> <div className="font-medium">{renderName(event.name)}</div>
<div className="text-muted-foreground">Slug: {e.slug} · Datum: {e.date ?? '-'}</div> <div className="text-muted-foreground">Slug: {event.slug} <EFBFBD> Datum: {event.date ?? '-'}</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-sm underline">
<Link to={`/admin/events/view?id=${e.id}`} className="text-sm underline">details</Link> <Link to={`/admin/events/view?id=${event.id}`}>details</Link>
<Link to={`/admin/events/edit?id=${e.id}`} className="text-sm underline">bearbeiten</Link> <Link to={`/admin/events/edit?id=${event.id}`}>bearbeiten</Link>
<Link to={`/admin/events/photos?id=${e.id}`} className="text-sm underline">fotos</Link> <Link to={`/admin/events/photos?id=${event.id}`}>fotos</Link>
<a className="text-sm underline" href={`/e/${e.slug}`} target="_blank">öffnen</a> <a href={`/e/${event.slug}`} target="_blank" rel="noreferrer">
<EFBFBD>ffnen
</a>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,43 +1,61 @@
import React from 'react'; import React from 'react';
import { login } from '../api'; import { Location, useLocation, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { useAuth } from '../auth/context';
interface LocationState {
from?: Location;
}
export default function LoginPage() { export default function LoginPage() {
const nav = useNavigate(); const { status, login } = useAuth();
const [email, setEmail] = React.useState(''); const location = useLocation();
const [password, setPassword] = React.useState(''); const navigate = useNavigate();
const [error, setError] = React.useState<string | null>(null); const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const [loading, setLoading] = React.useState(false); const oauthError = searchParams.get('error');
async function submit(e: React.FormEvent) { React.useEffect(() => {
e.preventDefault(); if (status === 'authenticated') {
setError(null); navigate('/admin', { replace: true });
setLoading(true); }
try { }, [status, navigate]);
const { token } = await login(email, password);
localStorage.setItem('ta_token', token); const redirectTarget = React.useMemo(() => {
nav('/admin', { replace: true }); const state = location.state as LocationState | null;
} catch (err: any) { if (state?.from) {
setError('Login fehlgeschlagen'); const from = state.from;
} finally { setLoading(false); } const search = from.search ?? '';
} const hash = from.hash ?? '';
return `${from.pathname}${search}${hash}`;
}
return '/admin';
}, [location.state]);
return ( return (
<div className="mx-auto max-w-sm p-6"> <div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h1 className="text-lg font-semibold">Tenant Admin</h1> <h1 className="text-lg font-semibold">Tenant Admin</h1>
<AppearanceToggleDropdown /> <AppearanceToggleDropdown />
</div> </div>
<form onSubmit={submit} className="space-y-3"> <div className="space-y-4 text-sm text-muted-foreground">
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>} <p>
<Input placeholder="E-Mail" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach
<Input placeholder="Passwort" type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> wieder zur Admin-Oberfl<EFBFBD>che gebracht.
<Button type="submit" disabled={loading || !email || !password} className="w-full">{loading ? 'Bitte warten…' : 'Anmelden'}</Button> </p>
</form> {oauthError && (
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
Anmeldung fehlgeschlagen: {oauthError}
</div>
)}
<Button
className="w-full"
disabled={status === 'loading'}
onClick={() => login(redirectTarget)}
>
{status === 'loading' ? 'Bitte warten <20>' : 'Mit Tenant-Account anmelden'}
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -2,22 +2,34 @@ import React from 'react';
import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/context';
export default function SettingsPage() { export default function SettingsPage() {
const nav = useNavigate(); const nav = useNavigate();
function logout() { const { user, logout } = useAuth();
localStorage.removeItem('ta_token');
nav('/admin/login', { replace: true }); function handleLogout() {
logout({ redirect: '/admin/login' });
} }
return ( return (
<div className="mx-auto max-w-sm p-6"> <div className="mx-auto max-w-sm space-y-4 p-6">
<h1 className="mb-4 text-lg font-semibold">Einstellungen</h1> <div>
<div className="mb-4"> <h1 className="text-lg font-semibold">Einstellungen</h1>
{user && (
<p className="mt-1 text-sm text-muted-foreground">
Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id}
</p>
)}
</div>
<div>
<div className="text-sm font-medium">Darstellung</div> <div className="text-sm font-medium">Darstellung</div>
<AppearanceToggleDropdown /> <AppearanceToggleDropdown />
</div> </div>
<Button variant="destructive" onClick={logout}>Abmelden</Button> <div className="flex gap-2">
<Button variant="destructive" onClick={handleLogout}>Abmelden</Button>
<Button variant="secondary" onClick={() => nav(-1)}>Zurück</Button>
</div>
</div> </div>
); );
} }

View File

@@ -1,20 +1,36 @@
import React from 'react'; 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 LoginPage from './pages/LoginPage';
import EventsPage from './pages/EventsPage'; import EventsPage from './pages/EventsPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import EventFormPage from './pages/EventFormPage'; import EventFormPage from './pages/EventFormPage';
import EventPhotosPage from './pages/EventPhotosPage'; import EventPhotosPage from './pages/EventPhotosPage';
import EventDetailPage from './pages/EventDetailPage'; import EventDetailPage from './pages/EventDetailPage';
import AuthCallbackPage from './pages/AuthCallbackPage';
import { useAuth } from './auth/context';
function RequireAuth() { function RequireAuth() {
const token = localStorage.getItem('ta_token'); const { status } = useAuth();
if (!token) return <Navigate to="/admin/login" replace />; const location = useLocation();
if (status === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Bitte warten <EFBFBD>
</div>
);
}
if (status === 'unauthenticated') {
return <Navigate to="/admin/login" state={{ from: location }} replace />;
}
return <Outlet />; return <Outlet />;
} }
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ path: '/admin/login', element: <LoginPage /> }, { path: '/admin/login', element: <LoginPage /> },
{ path: '/admin/auth/callback', element: <AuthCallbackPage /> },
{ {
path: '/admin', path: '/admin',
element: <RequireAuth />, element: <RequireAuth />,

View File

@@ -1,18 +1,25 @@
import React from 'react'; 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 AppearanceToggleDropdown from '@/components/appearance-dropdown'; import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Settings, ChevronDown, User } from 'lucide-react'; import { User } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useEventData } from '../hooks/useEventData'; 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 }) { export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
const statsContext = useOptionalEventStats();
const identity = useOptionalGuestIdentity();
if (!slug) { if (!slug) {
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
return ( return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40"> <div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
<div className="font-semibold">{title}</div> <div className="flex flex-col">
<div className="font-semibold">{title}</div>
{guestName && (
<span className="text-xs text-muted-foreground">Hi {guestName}</span>
)}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AppearanceToggleDropdown /> <AppearanceToggleDropdown />
<SettingsSheet /> <SettingsSheet />
@@ -22,7 +29,8 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
} }
const { event, loading: eventLoading, error: eventError } = useEventData(); 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) { if (eventLoading) {
return ( 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) => { const getEventAvatar = (event: any) => {
if (event.type?.icon) { if (event.type?.icon) {
return ( return (
@@ -58,7 +65,6 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
); );
} }
// Fallback to initials
const getInitials = (name: string) => { const getInitials = (name: string) => {
const words = name.split(' '); const words = name.split(' ');
if (words.length >= 2) { if (words.length >= 2) {
@@ -80,6 +86,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
{getEventAvatar(event)} {getEventAvatar(event)}
<div className="flex flex-col"> <div className="flex flex-col">
<div className="font-semibold text-base">{event.name}</div> <div className="font-semibold text-base">{event.name}</div>
{guestName && (
<span className="text-xs text-muted-foreground">Hi {guestName}</span>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
{stats && ( {stats && (
<> <>
@@ -87,9 +96,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
<User className="h-3 w-3" /> <User className="h-3 w-3" />
<span>{stats.onlineGuests} online</span> <span>{stats.onlineGuests} online</span>
</span> </span>
<span></span> <span className="text-muted-foreground">|</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben gelöst <span className="font-medium">{stats.tasksSolved}</span> Aufgaben geloest
</span> </span>
</> </>
)} )}
@@ -104,88 +113,4 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
); );
} }
function SettingsSheet() { export {}
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
<Settings className="h-5 w-5" />
<span className="sr-only">Einstellungen öffnen</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 sm:w-96">
<SheetHeader>
<SheetTitle>Einstellungen</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-4">
<Collapsible defaultOpen>
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Cache</div>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2">
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="mt-2">
<ClearCacheButton />
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible defaultOpen>
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Rechtliches</div>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2">
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<ul className="mt-2 list-disc pl-5 text-sm">
<li><Link to="/legal/impressum" className="underline">Impressum</Link></li>
<li><Link to="/legal/datenschutz" className="underline">Datenschutz</Link></li>
<li><Link to="/legal/agb" className="underline">AGB</Link></li>
</ul>
</CollapsibleContent>
</Collapsible>
</div>
</SheetContent>
</Sheet>
);
}
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 (
<div className="mt-2">
<Button variant="secondary" onClick={clearAll} disabled={busy}>
{busy ? 'Leere Cache…' : 'Cache leeren'}
</Button>
{done && <div className="mt-2 text-xs text-muted-foreground">Cache gelöscht.</div>}
</div>
);
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
safe = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
safe = safe.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
safe = safe
.split(/\n{2,}/)
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
.join('\n');
return safe;
}, [markdown]);
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
}

View File

@@ -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<ViewState>({ mode: 'home' });
const identity = useOptionalGuestIdentity();
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [nameStatus, setNameStatus] = React.useState<NameStatus>('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 (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
<Settings className="h-5 w-5" />
<span className="sr-only">Einstellungen oeffnen</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="sm:max-w-md">
<div className="flex h-full flex-col">
<header className="border-b bg-background px-6 py-4">
{isLegal ? (
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={handleBack}
>
<ArrowLeft className="h-5 w-5" />
<span className="sr-only">Zurück</span>
</Button>
<div className="min-w-0">
<SheetTitle className="truncate">
{legalDocument.phase === 'ready' && legalDocument.title
? legalDocument.title
: view.label}
</SheetTitle>
<SheetDescription>
{legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'}
</SheetDescription>
</div>
</div>
) : (
<div>
<SheetTitle>Einstellungen</SheetTitle>
<SheetDescription>
Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.
</SheetDescription>
</div>
)}
</header>
<main className="flex-1 overflow-y-auto px-6 py-4">
{isLegal ? (
<LegalView document={legalDocument} onClose={() => handleOpenChange(false)} />
) : (
<HomeView
identity={identity}
nameDraft={nameDraft}
onNameChange={setNameDraft}
onSaveName={handleSaveName}
onResetName={handleResetName}
canSaveName={canSaveName}
savingName={savingName}
nameStatus={nameStatus}
onOpenLegal={handleOpenLegal}
/>
)}
</main>
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground">
<div>Gastbereich - Daten werden lokal im Browser gespeichert.</div>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
);
}
function LegalView({ document, onClose }: { document: LegalDocumentState; onClose: () => void }) {
if (document.phase === 'error') {
return (
<div className="space-y-4">
<Alert variant="destructive">
<AlertDescription>
Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.
</AlertDescription>
</Alert>
<Button variant="secondary" onClick={onClose}>
Schliessen
</Button>
</div>
);
}
if (document.phase === 'loading' || document.phase === 'idle') {
return <div className="text-sm text-muted-foreground">Dokument wird geladen...</div>;
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{document.title || 'Rechtlicher Hinweis'}</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
<LegalMarkdown markdown={document.body} />
</CardContent>
</Card>
</div>
);
}
interface HomeViewProps {
identity: ReturnType<typeof useOptionalGuestIdentity>;
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 (
<div className="space-y-6">
{identity && (
<Card>
<CardHeader className="pb-3">
<CardTitle>Dein Name</CardTitle>
<CardDescription>
Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 text-pink-600">
<UserCircle className="h-6 w-6" />
</div>
<div className="flex-1 space-y-2">
<Label htmlFor="guest-name" className="text-sm font-medium">
Anzeigename
</Label>
<Input
id="guest-name"
value={nameDraft}
placeholder="z.B. Anna"
onChange={(event) => onNameChange(event.target.value)}
autoComplete="name"
disabled={!identity.hydrated || savingName}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={onSaveName} disabled={!canSaveName || savingName}>
{savingName ? 'Speichere...' : 'Name speichern'}
</Button>
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
zurücksetzen
</Button>
{nameStatus === 'saved' && (
<span className="text-xs text-muted-foreground">Gespeichert (ok)</span>
)}
{!identity.hydrated && (
<span className="text-xs text-muted-foreground">Lade gespeicherten Namen...</span>
)}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-pink-500" />
Rechtliches
</div>
</CardTitle>
<CardDescription>
Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{legalPages.map((page) => (
<Button
key={page.slug}
variant="ghost"
className="w-full justify-between px-3"
onClick={() => onOpenLegal(page.slug, page.label)}
>
<span className="text-left text-sm">{page.label}</span>
<ChevronRight className="h-4 w-4" />
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Offline Cache</CardTitle>
<CardDescription>
Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ClearCacheButton />
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<RefreshCcw className="mt-0.5 h-3.5 w-3.5" />
<span>Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.</span>
</div>
</CardContent>
</Card>
</div>
);
}
function useLegalDocument(slug: string | null): LegalDocumentState {
const [state, setState] = React.useState<LegalDocumentState>({
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 (
<div className="space-y-2">
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full">
{busy ? 'Leere Cache...' : 'Cache leeren'}
</Button>
{done && <div className="text-xs text-muted-foreground">Cache geloescht.</div>}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { usePollStats } from '../polling/usePollStats';
type EventStatsContextValue = ReturnType<typeof usePollStats> & {
slug: string;
};
const EventStatsContext = React.createContext<EventStatsContextValue | undefined>(undefined);
export function EventStatsProvider({ slug, children }: { slug: string; children: React.ReactNode }) {
const stats = usePollStats(slug);
const value = React.useMemo<EventStatsContextValue>(
() => ({ slug, ...stats }),
[slug, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading]
);
return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
}
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);
}

View File

@@ -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<GuestIdentityContextValue | undefined>(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<GuestIdentityContextValue>(
() => ({
slug,
name,
hydrated,
setName: persistName,
clearName,
reload: loadFromStorage,
}),
[slug, name, hydrated, persistName, clearName, loadFromStorage]
);
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
}
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);
}

View File

@@ -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<number[]>([]);
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,
};
}

View File

@@ -1,11 +1,444 @@
import React from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Page } from './_util'; 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 ( return (
<Page title="Erfolge"> <Card>
<p>Badges and progress placeholder.</p> <CardHeader className="flex flex-row items-center gap-2 pb-4">
</Page> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
<Icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-base font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">Top 5 Teilnehmer dieses Events</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-2">
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyCopy}</p>
) : (
<ol className="space-y-2 text-sm">
{entries.map((entry, index) => (
<li key={`${entry.guest}-${index}`} className="flex items-center justify-between rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-muted-foreground">#{index + 1}</span>
<span className="font-medium text-foreground">{entry.guest || 'Gast'}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{entry.photos} Fotos</span>
<span>{entry.likes} Likes</span>
</div>
</li>
))}
</ol>
)}
</CardContent>
</Card>
); );
} }
function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
if (badges.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Badges</CardTitle>
<CardDescription>Erfuelle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Noch keine Badges verfuegbar.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Badges</CardTitle>
<CardDescription>Dein Fortschritt bei den verfuegbaren Erfolgen.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{badges.map((badge) => (
<div key={badge.id} className={cn('rounded-xl border px-4 py-3', badgeVariant(badge.earned))}>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold">{badge.title}</p>
<p className="text-xs text-muted-foreground">{badge.description}</p>
</div>
<Award className="h-5 w-5" />
</div>
<div className="mt-2 text-xs text-muted-foreground">
{badge.earned ? 'Abgeschlossen' : `Fortschritt: ${badge.progress}/${badge.target}`}
</div>
</div>
))}
</CardContent>
</Card>
);
}
function Timeline({ points }: { points: TimelinePoint[] }) {
if (points.length === 0) {
return null;
}
return (
<Card>
<CardHeader>
<CardTitle>Timeline</CardTitle>
<CardDescription>Wie das Event im Laufe der Zeit Fahrt aufgenommen hat.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{points.map((point) => (
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
<span className="font-medium text-foreground">{point.date}</span>
<span className="text-muted-foreground">{point.photos} Fotos | {point.guests} Gaeste</span>
</div>
))}
</CardContent>
</Card>
);
}
function Feed({ feed }: { feed: FeedEntry[] }) {
if (feed.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Live Feed</CardTitle>
<CardDescription>Neue Uploads erscheinen hier in Echtzeit.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Noch keine Uploads - starte die Kamera und lege los!</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Live Feed</CardTitle>
<CardDescription>Die neuesten Momente aus deinem Event.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{feed.map((item) => (
<div key={item.photoId} className="flex items-center gap-3 rounded-lg border border-border/40 bg-muted/20 p-3">
{item.thumbnail ? (
<img src={item.thumbnail} alt="Vorschau" className="h-16 w-16 rounded-md object-cover" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-md bg-muted"> <Camera className="h-6 w-6 text-muted-foreground" /> </div>
)}
<div className="flex-1 text-sm">
<p className="font-semibold text-foreground">{item.guest || 'Gast'}</p>
{item.task && <p className="text-xs text-muted-foreground">Aufgabe: {item.task}</p>}
<div className="mt-1 flex items-center gap-4 text-xs text-muted-foreground">
<span>{formatRelativeTime(item.createdAt)}</span>
<span>{item.likes} Likes</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
);
}
function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null }) {
if (!topPhoto && !trendingEmotion) {
return null;
}
return (
<div className="grid gap-4 md:grid-cols-2">
{topPhoto && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Publikumsliebling</CardTitle>
<CardDescription>Das Foto mit den meisten Likes.</CardDescription>
</div>
<Trophy className="h-6 w-6 text-amber-400" />
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="overflow-hidden rounded-xl border border-border/40">
{topPhoto.thumbnail ? (
<img src={topPhoto.thumbnail} alt="Top Foto" className="h-48 w-full object-cover" />
) : (
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">Kein Vorschau-Bild</div>
)}
</div>
<p><span className="font-semibold text-foreground">{topPhoto.guest || 'Gast'}</span> <EFBFBD> {topPhoto.likes} Likes</p>
{topPhoto.task && <p className="text-muted-foreground">Aufgabe: {topPhoto.task}</p>}
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
</CardContent>
</Card>
)}
{trendingEmotion && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Trend-Emotion</CardTitle>
<CardDescription>Diese Stimmung taucht gerade besonders oft auf.</CardDescription>
</div>
<Flame className="h-6 w-6 text-pink-500" />
</CardHeader>
<CardContent>
<p className="text-lg font-semibold text-foreground">{trendingEmotion.name}</p>
<p className="text-sm text-muted-foreground">{trendingEmotion.count} Fotos mit dieser Stimmung</p>
</CardContent>
</Card>
)}
</div>
);
}
function SummaryCards({ data }: { data: AchievementsPayload }) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Fotos gesamt</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.totalPhotos)}</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Aktive Gaeste</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.uniqueGuests)}</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Erfuellte Aufgaben</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.tasksSolved)}</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Likes insgesamt</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.likesTotal)}</span>
</CardContent>
</Card>
</div>
);
}
function PersonalActions({ slug }: { slug: string }) {
return (
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link to={`/e/${encodeURIComponent(slug)}/upload`} className="flex items-center gap-2">
<Camera className="h-4 w-4" />
Neues Foto hochladen
</Link>
</Button>
<Button variant="outline" asChild>
<Link to={`/e/${encodeURIComponent(slug)}/tasks`} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Aufgabe ziehen
</Link>
</Button>
</div>
);
}
export default function AchievementsPage() {
const { slug } = useParams<{ slug: string }>();
const identity = useGuestIdentity();
const [data, setData] = useState<AchievementsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6 pb-24">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
<Award className="h-5 w-5" />
</div>
<div>
<h1 className="text-2xl font-semibold text-foreground">Erfolge</h1>
<p className="text-sm text-muted-foreground">Behalte deine Highlights, Badges und die aktivsten Gaeste im Blick.</p>
</div>
</div>
</div>
{loading && (
<div className="space-y-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
)}
{!loading && error && (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={() => setActiveTab(hasPersonal ? 'personal' : 'event')}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
)}
{!loading && !error && data && (
<>
<SummaryCards data={data} />
<div className="flex flex-wrap items-center gap-2">
<Button
variant={activeTab === 'personal' ? 'default' : 'outline'}
onClick={() => setActiveTab('personal')}
disabled={!hasPersonal}
className="flex items-center gap-2"
>
<Sparkles className="h-4 w-4" />
Meine Erfolge
</Button>
<Button
variant={activeTab === 'event' ? 'default' : 'outline'}
onClick={() => setActiveTab('event')}
className="flex items-center gap-2"
>
<Users className="h-4 w-4" />
Event Highlights
</Button>
<Button
variant={activeTab === 'feed' ? 'default' : 'outline'}
onClick={() => setActiveTab('feed')}
className="flex items-center gap-2"
>
<BarChart2 className="h-4 w-4" />
Live Feed
</Button>
</div>
<Separator />
{activeTab === 'personal' && hasPersonal && data.personal && (
<div className="space-y-5">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg font-semibold">Hi {data.personal.guestName || identity.name || 'Gast'}!</CardTitle>
<CardDescription>
{data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes
</CardDescription>
</div>
<PersonalActions slug={slug} />
</CardHeader>
</Card>
<BadgesGrid badges={data.personal.badges} />
</div>
)}
{activeTab === 'event' && (
<div className="space-y-5">
<Highlights topPhoto={data.highlights.topPhoto} trendingEmotion={data.highlights.trendingEmotion} />
<Timeline points={data.highlights.timeline} />
<div className="grid gap-4 lg:grid-cols-2">
<Leaderboard
title="Top Uploads"
icon={Users}
entries={data.leaderboards.uploads}
emptyCopy="Noch keine Uploads - sobald Fotos vorhanden sind, erscheinen sie hier."
/>
<Leaderboard
title="Beliebteste Gaeste"
icon={Trophy}
entries={data.leaderboards.likes}
emptyCopy="Likes fehlen noch - motiviere die Gaeste, Fotos zu liken."
/>
</div>
</div>
)}
{activeTab === 'feed' && <Feed feed={data.feed} />}
</>
)}
</div>
);
}

View File

@@ -106,17 +106,7 @@ export default function GalleryPage() {
imageUrl = imageUrl.replace(/\/+/g, '/'); imageUrl = imageUrl.replace(/\/+/g, '/');
} }
// Extended debug logging // Production: avoid heavy console logging for each image
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/')
});
return ( return (
<Card key={p.id} className="relative overflow-hidden"> <Card key={p.id} className="relative overflow-hidden">
@@ -133,11 +123,8 @@ export default function GalleryPage() {
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`} alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
className="aspect-square w-full object-cover bg-gray-200" className="aspect-square w-full object-cover bg-gray-200"
onError={(e) => { 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=='; (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}} }}
onLoad={() => console.log(`✅ Successfully loaded image ${p.id}:`, imageUrl)}
loading="lazy" loading="lazy"
/> />
</div> </div>

View File

@@ -1,37 +1,186 @@
import React from 'react'; import React from 'react';
import { Page } from './_util'; import { Link, useParams } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { usePollStats } from '../polling/usePollStats';
import { Button } from '@/components/ui/button'; 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 EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview'; 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() { export default function HomePage() {
const { slug } = useParams(); const { slug } = useParams<{ slug: string }>();
const stats = usePollStats(slug!); 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: <Sparkles className="h-5 w-5" />,
},
{
to: 'upload',
label: 'Direkt hochladen',
description: 'Teile deine neuesten Fotos',
icon: <UploadCloud className="h-5 w-5" />,
},
{
to: 'gallery',
label: 'Galerie ansehen',
description: 'Lass dich von anderen inspirieren',
icon: <Images className="h-5 w-5" />,
},
];
const checklistItems = [
'Aufgabe auswaehlen oder starten',
'Emotion festhalten und Foto schiessen',
'Bild hochladen und Credits sammeln',
];
return ( return (
<Page title={`Event: ${slug}`}> <div className="space-y-6 pb-24">
<Header slug={slug!} title={`Event: ${slug}`} /> <HeroCard name={displayName} eventName={event?.name ?? 'Dein Event'} tasksCompleted={completedCount} />
<div className="px-4 py-6 pb-20 space-y-6"> {/* Consistent spacing */}
{/* Prominent Draw Task Button */}
<Link to="tasks">
<Button className="w-full bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white py-4 rounded-xl text-base font-semibold mb-6 shadow-lg hover:shadow-xl transition-all duration-200">
<span className="flex items-center gap-2">
🎲 Aufgabe ziehen
</span>
</Button>
</Link>
{/* How do you feel? Section */} <Card>
<EmotionPicker /> <CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
<StatTile
icon={<Users className="h-4 w-4" />}
label="Gleichzeitig online"
value={`${stats.onlineGuests}`}
/>
<StatTile
icon={<Sparkles className="h-4 w-4" />}
label="Aufgaben gelöst"
value={`${stats.tasksSolved}`}
/>
<StatTile
icon={<TimerReset className="h-4 w-4" />}
label="Letzter Upload"
value={latestUploadText}
/>
<StatTile
icon={<CheckCircle2 className="h-4 w-4" />}
label="Deine erledigten Aufgaben"
value={`${completedCount}`}
/>
</CardContent>
</Card>
<GalleryPreview slug={slug!} /> <section className="space-y-3">
</div> <div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Deine Aktionen</h2>
<span className="text-xs text-muted-foreground">Waehle aus, womit du starten willst</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{primaryActions.map((action) => (
<Link to={action.to} key={action.to} className="block">
<Card className="transition-all hover:shadow-lg">
<CardContent className="flex items-center gap-3 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-pink-100 text-pink-600">
{action.icon}
</div>
<div className="flex flex-col">
<span className="text-base font-semibold">{action.label}</span>
<span className="text-sm text-muted-foreground">{action.description}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
<Button variant="outline" asChild className="w-full">
<Link to="queue">Uploads in Warteschlange ansehen</Link>
</Button>
</section>
{/* Bottom Navigation */} <Card>
<BottomNav /> <CardHeader>
</Page> <CardTitle>Dein Fortschritt</CardTitle>
<CardDescription>Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{checklistItems.map((item) => (
<div key={item} className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" />
<span className="text-sm leading-relaxed text-muted-foreground">{item}</span>
</div>
))}
</CardContent>
</Card>
<Separator />
<EmotionPicker />
<GalleryPreview slug={slug} />
</div>
); );
} }
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 (
<Card className="overflow-hidden border-0 bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-md">
<CardHeader className="space-y-1">
<CardDescription className="text-sm text-white/80">Willkommen zur Party</CardDescription>
<CardTitle className="text-2xl font-bold">Hey {name}!</CardTitle>
<p className="text-sm text-white/80">Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.</p>
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
</CardHeader>
</Card>
);
}
function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-pink-600 shadow-sm">
{icon}
</div>
<div className="flex flex-col">
<span className="text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-lg font-semibold text-foreground">{value}</span>
</div>
</div>
);
}
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`;
}

View File

@@ -1,18 +1,23 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Page } from './_util'; import { Page } from './_util';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; 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() { export default function LandingPage() {
const nav = useNavigate(); const nav = useNavigate();
const [slug, setSlug] = React.useState(''); const [slug, setSlug] = useState('');
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
async function join() { async function join(eventSlug?: string) {
const s = slug.trim(); const s = (eventSlug ?? slug).trim();
if (!s) return; if (!s) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -22,30 +27,131 @@ export default function LandingPage() {
setError('Event nicht gefunden oder geschlossen.'); setError('Event nicht gefunden oder geschlossen.');
return; return;
} }
nav(`/e/${encodeURIComponent(s)}`); const storedName = readGuestName(s);
if (!storedName) {
nav(`/setup/${encodeURIComponent(s)}`);
} else {
nav(`/e/${encodeURIComponent(s)}`);
}
} catch (e) { } catch (e) {
setError('Netzwerkfehler. Bitte später erneut versuchen.'); console.error('Join request failed', e);
setError('Netzwerkfehler. Bitte spaeter erneut versuchen.');
} finally { } finally {
setLoading(false); 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 ( return (
<Page title="Willkommen bei Fotochallenge 🎉"> <Page title="Willkommen bei der Fotobox!">
{error && ( {error && (
<Alert className="mb-3" variant="destructive"> <Alert className="mb-3" variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<Input <div className="space-y-6 pb-20">
value={slug} <div className="text-center space-y-2">
onChange={(e) => setSlug(e.target.value)} <h1 className="text-2xl font-bold text-gray-900">Willkommen bei der Fotobox!</h1>
placeholder="QR/PIN oder Event-Slug eingeben" <p className="text-lg text-gray-600">Dein Schluessel zu unvergesslichen Momenten.</p>
/> </div>
<div className="h-3" />
<Button disabled={loading || !slug.trim()} onClick={join}> <Card className="mx-auto w-full max-w-md">
{loading ? 'Prüfe…' : 'Event beitreten'} <CardHeader className="text-center">
</Button> <CardTitle className="text-xl font-semibold">Event beitreten</CardTitle>
<CardDescription>Scanne den QR-Code oder gib den Code manuell ein.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-6">
<div className="flex flex-col items-center space-y-3">
<div className="flex h-24 w-24 items-center justify-center rounded-lg bg-gray-200">
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
</div>
<div id="qr-reader" className="w-full" hidden={!isScanning} />
<div className="flex w-full flex-col gap-2 sm:flex-row">
<Button
variant="outline"
className="flex-1"
onClick={isScanning ? stopScanner : startScanner}
disabled={loading}
>
{isScanning ? 'Scanner stoppen' : 'QR-Code scannen'}
</Button>
</div>
</div>
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500">
Oder manuell eingeben
</div>
<div className="space-y-2">
<Input
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="Event-Code eingeben"
disabled={loading}
/>
<Button
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
disabled={loading || !slug.trim()}
onClick={() => join()}
>
{loading ? 'Pruefe...' : 'Event beitreten'}
</Button>
</div>
</CardContent>
</Card>
</div>
</Page> </Page>
); );
} }

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from "react";
import { Page } from './_util'; import { Page } from './_util';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { LegalMarkdown } from '../components/legal-markdown';
export default function LegalPage() { export default function LegalPage() {
const { page } = useParams(); const { page } = useParams();
@@ -9,42 +10,44 @@ export default function LegalPage() {
const [body, setBody] = React.useState(''); const [body, setBody] = React.useState('');
React.useEffect(() => { React.useEffect(() => {
async function load() { if (!page) {
setLoading(true); return;
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) 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]); }, [page]);
return ( return (
<Page title={title || `Rechtliches: ${page}` }> <Page title={title || `Rechtliches: ${page}` }>
{loading ? <p>Lädt</p> : <Markdown md={body} />} {loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
</Page> </Page>
); );
} }
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// bold **text**
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// italic *text*
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// links [text](url)
s = s.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1<\/a>');
// paragraphs
s = s.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br/>')}<\/p>`).join('\n');
return s;
}, [md]);
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
}

View File

@@ -1,13 +1,104 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Page } from './_util'; 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() { 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 (
<div className="flex justify-center items-center h-32">
<div className="text-lg">Lade Event...</div>
</div>
);
}
if (error || !event) {
return (
<div className="text-center p-4">
<p className="text-red-600 mb-4">{error || 'Event nicht gefunden.'}</p>
<Button onClick={() => nav('/')}>Zurück zur Startseite</Button>
</div>
);
}
return ( return (
<Page title="Profil erstellen"> <div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
<input placeholder="Dein Name" style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 8 }} /> <Header slug={slug!} />
<div style={{ height: 12 }} /> <div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
<button style={{ padding: '10px 16px', borderRadius: 8, background: '#111827', color: 'white' }}>Starten</button> <Card className="w-full max-w-md">
</Page> <CardHeader className="text-center space-y-2">
<CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle>
<CardDescription className="text-lg text-gray-600">
Fange den schoensten Moment ein!
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">Dein Name (z.B. Anna)</Label>
<Input
id="name"
value={name}
onChange={(e) => handleChange(e.target.value)}
placeholder="Dein Name"
className="text-lg"
disabled={submitting || !hydrated}
autoComplete="name"
/>
</div>
<Button
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white py-3 text-base font-semibold rounded-xl"
onClick={submitName}
disabled={submitting || !name.trim() || !hydrated}
>
{submitting ? 'Speichere...' : "Let's go!"}
</Button>
</CardContent>
</Card>
</div>
</div>
); );
} }

View File

@@ -1,19 +1,17 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Page } from './_util';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAppearance } from '../../hooks/use-appearance'; import { Badge } from '@/components/ui/badge';
import { Clock, RefreshCw, Smile } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert';
import BottomNav from '../components/BottomNav'; import { Sparkles, RefreshCw, Smile, Timer as TimerIcon, CheckCircle2, AlertTriangle } from 'lucide-react';
import { useEventData } from '../hooks/useEventData'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { EventData } from '../services/eventApi';
interface Task { interface Task {
id: number; id: number;
title: string; title: string;
description: string; description: string;
instructions: string; instructions: string;
duration: number; // in minutes duration: number; // minutes
emotion?: { emotion?: {
slug: string; slug: string;
name: string; name: string;
@@ -21,244 +19,508 @@ interface Task {
is_completed: boolean; 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() { export default function TaskPickerPage() {
const { slug } = useParams<{ slug: string }>(); 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 navigate = useNavigate();
const { appearance } = useAppearance(); const [searchParams, setSearchParams] = useSearchParams();
const isDark = appearance === 'dark';
const [tasks, setTasks] = useState<Task[]>([]); const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
const [currentTask, setCurrentTask] = useState<Task | null>(null);
const [timeLeft, setTimeLeft] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Timer state const [tasks, setTasks] = React.useState<Task[]>([]);
useEffect(() => { const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
if (!currentTask) return; const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [selectedEmotion, setSelectedEmotion] = React.useState<string>('all');
const [timeLeft, setTimeLeft] = React.useState<number>(0);
const [timerRunning, setTimerRunning] = React.useState(false);
const [timeUp, setTimeUp] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const durationMs = currentTask.duration * 60 * 1000; const recentTaskIdsRef = React.useRef<number[]>([]);
setTimeLeft(durationMs / 1000); const initialEmotionRef = React.useRef(false);
const interval = setInterval(() => { const fetchTasks = React.useCallback(async () => {
setTimeLeft(prev => { 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<EmotionOption[]>(() => {
const map = new Map<string, string>();
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) { if (prev <= 1) {
clearInterval(interval); window.clearInterval(tick);
triggerTimeUp();
return 0; return 0;
} }
return prev - 1; return prev - 1;
}); });
}, 1000); }, 1000);
return () => window.clearInterval(tick);
}, [timerRunning, timeLeft]);
return () => clearInterval(interval); function triggerTimeUp() {
}, [currentTask]); const supportsVibration = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
setTimerRunning(false);
// Load tasks setTimeUp(true);
useEffect(() => { if (supportsVibration) {
if (!slug) return; try {
navigator.vibrate(TIMER_VIBRATION);
async function fetchTasks() { } catch (error) {
try { console.warn('Vibration not permitted', error);
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);
}
} }
}
window.setTimeout(() => setTimeUp(false), 4000);
return;
}
fetchTasks(); const formatTime = React.useCallback((seconds: number) => {
}, [slug]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = seconds % 60; const secs = Math.max(0, seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`; 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 = () => { const handleNewTask = () => {
if (tasks.length === 0) return; selectRandomTask(filteredTasks);
const randomIndex = Math.floor(Math.random() * tasks.length);
setCurrentTask(tasks[randomIndex]);
setTimeLeft(tasks[randomIndex].duration * 60);
}; };
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; if (!currentTask) return;
// Navigate to upload with task context markCompleted(currentTask.id);
navigate(`/e/${slug}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`); selectRandomTask(filteredTasks);
}; };
const handleChangeMood = () => { const handleRetryFetch = () => {
navigate(`/e/${slug}`); fetchTasks();
}; };
if (loading) { const handleTimerToggle = () => {
return ( if (!currentTask) return;
<Page title="Aufgabe laden..."> if (timerRunning) {
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${ setTimerRunning(false);
isDark ? 'text-white' : 'text-gray-900' setTimeLeft(currentTask.duration * 60);
}`}> setTimeUp(false);
<RefreshCw className="h-8 w-8 animate-spin mb-4" /> } else {
<p className="text-sm">Lade Aufgabe...</p> if (timeLeft <= 0) {
</div> setTimeLeft(currentTask.duration * 60);
<BottomNav /> }
</Page> setTimerRunning(true);
); setTimeUp(false);
} }
};
if (error || !currentTask) { const emptyState = !loading && (!filteredTasks.length || !currentTask);
return (
<Page title="Keine Aufgaben verfügbar">
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 space-y-4 ${
isDark ? 'text-white' : 'text-gray-900'
}`}>
<Smile className="h-12 w-12 text-pink-500" />
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">Keine passende Aufgabe gefunden</h2>
<p className="text-sm text-muted-foreground max-w-md">
{error || 'Für deine Stimmung gibt es derzeit keine Aufgaben. Versuche eine andere Stimmung oder warte auf neue Inhalte.'}
</p>
</div>
<Button
variant="outline"
onClick={handleChangeMood}
className="w-full max-w-sm"
>
Andere Stimmung wählen
</Button>
</div>
<BottomNav />
</Page>
);
}
return ( return (
<Page title={currentTask.title}> <div className="space-y-6">
<div className={`min-h-screen flex flex-col ${ <header className="space-y-3">
isDark ? 'bg-gray-900 text-white' : 'bg-white text-gray-900' <div className="flex items-center justify-between gap-4">
}`}> <h1 className="text-2xl font-semibold text-foreground">Aufgabe auswaehlen</h1>
{/* Task Header with Selfie Overlay */} <Badge variant="secondary" className="whitespace-nowrap">
<div className="p-4 space-y-4"> Schon {completedCount} Aufgaben erledigt
<div className="relative"> </Badge>
{/* Selfie Placeholder */} </div>
<div className={`w-full aspect-square rounded-2xl bg-gradient-to-br ${ <div className="rounded-xl border bg-muted/40 p-4">
isDark <div className="flex items-center justify-between text-sm font-medium text-muted-foreground">
? 'from-gray-800 to-gray-700 shadow-2xl' <span>Auf dem Weg zum naechsten Erfolg</span>
: 'from-pink-50 to-pink-100 shadow-lg' <span>
} flex items-center justify-center`}> {completedCount >= TASK_PROGRESS_TARGET
<div className="text-center space-y-2"> ? 'Stark!'
<div className={`w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-2 ${ : `${Math.max(0, TASK_PROGRESS_TARGET - completedCount)} bis zum Badge`}
isDark ? 'text-white' : 'text-gray-600' </span>
}`}> </div>
📸 <div className="mt-3 h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all"
style={{ width: `${progressRatio * 100}%` }}
/>
</div>
</div>
{emotionOptions.length > 0 && (
<div className="flex flex-wrap gap-2">
<EmotionChip
active={selectedEmotion === 'all'}
label="Alle Stimmungen"
onClick={() => handleSelectEmotion('all')}
/>
{emotionOptions.map((emotion) => (
<EmotionChip
key={emotion.slug}
active={selectedEmotion === emotion.slug}
label={emotion.name}
onClick={() => handleSelectEmotion(emotion.slug)}
/>
))}
</div>
)}
</header>
{loading && (
<div className="space-y-4">
<SkeletonBlock />
<SkeletonBlock />
</div>
)}
{error && !loading && (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={handleRetryFetch} disabled={isFetching}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
)}
{emptyState && (
<EmptyState
hasTasks={Boolean(tasks.length)}
onRetry={handleRetryFetch}
emotionOptions={emotionOptions}
onEmotionSelect={handleSelectEmotion}
/>
)}
{!emptyState && currentTask && (
<div className="space-y-6">
<article className="overflow-hidden rounded-2xl border bg-card">
<div className="relative">
<div className="flex aspect-video items-center justify-center bg-gradient-to-br from-pink-500/80 via-purple-500/60 to-indigo-500/60 text-white">
<div className="text-center">
<Sparkles className="mx-auto mb-3 h-10 w-10" />
<p className="text-sm uppercase tracking-[.2em]">Deine Mission</p>
<h2 className="mt-2 text-2xl font-semibold">{currentTask.title}</h2>
</div> </div>
<p className={`text-sm font-medium ${
isDark ? 'text-gray-300' : 'text-gray-600'
}`}>
Selfie-Vorschau
</p>
</div> </div>
</div> <div className="absolute right-3 top-3 flex flex-col items-end gap-2">
<BadgeTimer
{/* Timer */} label="Countdown"
<div className="absolute top-2 right-2"> value={formatTime(timeLeft)}
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${ tone={timerTone(timeLeft, currentTask.duration)}
timeLeft > 60 />
? 'bg-green-500/20 text-green-400 border-green-500/30' {timeUp && (
: timeLeft > 30 <Badge variant="destructive" className="flex items-center gap-1">
? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' <AlertTriangle className="h-3.5 w-3.5" />
: 'bg-red-500/20 text-red-400 border-red-500/30' Zeit abgelaufen!
} border`}> </Badge>
<Clock className="h-3 w-3" />
<span>{formatTime(timeLeft)}</span>
</div>
</div>
{/* Task Description Overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-transparent to-transparent p-4 rounded-b-2xl">
<div className="space-y-2">
<h1 className={`text-xl font-bold ${
isDark ? 'text-gray-100' : 'text-white'
}`}>
{currentTask.title}
</h1>
<p className={`text-sm leading-relaxed ${
isDark ? 'text-gray-200' : 'text-gray-100'
}`}>
{currentTask.description}
</p>
{currentTask.instructions && (
<div className={`p-2 rounded-lg ${
isDark
? 'bg-gray-700/80 text-gray-100'
: 'bg-gray-800/80 text-white border border-gray-600/50'
}`}>
<p className="text-xs italic">💡 {currentTask.instructions}</p>
</div>
)} )}
</div> </div>
</div> </div>
</div>
</div>
{/* Action Buttons */} <div className="space-y-4 p-5">
<div className="px-4 pb-4 space-y-3"> <div className="flex flex-wrap gap-2">
<Button <Badge variant="outline" className="flex items-center gap-2">
onClick={handleStartTask} <TimerIcon className="h-4 w-4" />
className="w-full h-14 bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white rounded-xl text-base font-semibold shadow-lg hover:shadow-xl transition-all duration-200" {currentTask.duration} Min
> </Badge>
<span className="flex items-center justify-center gap-2"> {currentTask.emotion?.name && (
📸 Los geht's <Badge variant="outline" className="flex items-center gap-2">
</span> <Smile className="h-4 w-4" />
</Button> {currentTask.emotion.name}
</Badge>
)}
{isCompleted(currentTask.id) && (
<Badge variant="secondary" className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Bereits erledigt
</Badge>
)}
</div>
<div className="flex gap-3"> <p className="text-sm leading-relaxed text-muted-foreground">{currentTask.description}</p>
<Button
variant="outline" {currentTask.instructions && (
onClick={handleNewTask} <div className="rounded-xl border border-dashed border-pink-200 bg-pink-50/60 p-4 text-sm font-medium text-pink-800 dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-100">
className="flex-1 h-12 border-gray-300 dark:border-gray-600 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800" {currentTask.instructions}
> </div>
<RefreshCw className="h-4 w-4 mr-2" /> )}
Neue Aufgabe
<ul className="space-y-2 text-sm">
<ChecklistItem text="Stimme dich auf die Aufgabe ein." />
<ChecklistItem text="Hol dir dein Team oder Motiv ins Bild." />
<ChecklistItem text="Halte Emotion und Aufgabe im Foto fest." />
</ul>
{timerRunning && currentTask.duration > 0 && (
<div className="mt-4">
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>Countdown</span>
<span>Restzeit: {formatTime(timeLeft)}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-gradient-to-r from-amber-400 to-rose-500 transition-all"
style={{
width: `${Math.max(0, Math.min(100, (timeLeft / (currentTask.duration * 60)) * 100))}%`,
}}
/>
</div>
</div>
)}
</div>
</article>
<div className="grid gap-3 sm:grid-cols-2">
<Button onClick={handleStartUpload} className="h-14 text-base font-semibold">
<span className="flex items-center justify-center gap-2">
<Sparkles className="h-5 w-5" />
Los geht's
</span>
</Button> </Button>
<Button <Button
variant="ghost" variant={timerRunning ? 'destructive' : 'outline'}
onClick={handleChangeMood} onClick={handleTimerToggle}
className="flex-1 h-12 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800" className="h-14 text-base"
> >
<Smile className="h-4 w-4 mr-2" /> <span className="flex items-center justify-center gap-2">
Andere Stimmung <TimerIcon className="h-5 w-5" />
{timerRunning ? 'Timer stoppen' : 'Timer starten'}
</span>
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Button variant="secondary" onClick={handleMarkCompleted} className="h-12">
<span className="flex items-center justify-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Aufgabe erledigt
</span>
</Button>
<Button variant="ghost" onClick={handleNewTask} className="h-12">
<span className="flex items-center justify-center gap-2">
<RefreshCw className="h-5 w-5" />
Neue Aufgabe anzeigen
</span>
</Button> </Button>
</div> </div>
</div> </div>
)}
{/* Bottom Navigation */} {!loading && !tasks.length && !error && (
<BottomNav /> <Alert>
</div> <AlertDescription>Fuer dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
</Page> </Alert>
)}
</div>
);
}
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 (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-4 py-1 text-sm transition ${
active
? 'border-pink-500 bg-pink-500 text-white shadow-sm'
: 'border-border bg-background text-muted-foreground hover:border-pink-400 hover:text-foreground'
}`}
>
{label}
</button>
);
}
function ChecklistItem({ text }: { text: string }) {
return (
<li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
<span className="text-muted-foreground">{text}</span>
</li>
);
}
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 (
<div className={`flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium ${toneClasses}`}>
<TimerIcon className="h-3.5 w-3.5" />
<span>{label}</span>
<span>{value}</span>
</div>
);
}
function SkeletonBlock() {
return <div className="h-24 animate-pulse rounded-xl bg-muted/60" />;
}
function EmptyState({
hasTasks,
onRetry,
emotionOptions,
onEmotionSelect,
}: {
hasTasks: boolean;
onRetry: () => void;
emotionOptions: EmotionOption[];
onEmotionSelect: (slug: string) => void;
}) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-muted-foreground/30 bg-muted/20 p-8 text-center">
<Smile className="h-12 w-12 text-pink-500" />
<div className="space-y-2">
<h2 className="text-xl font-semibold">Keine passende Aufgabe gefunden</h2>
<p className="text-sm text-muted-foreground">
{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.'}
</p>
</div>
{hasTasks && emotionOptions.length > 0 && (
<div className="flex flex-wrap justify-center gap-2">
{emotionOptions.map((emotion) => (
<EmotionChip
key={emotion.slug}
label={emotion.name}
active={false}
onClick={() => onEmotionSelect(emotion.slug)}
/>
))}
</div>
)}
<Button onClick={onRetry} variant="outline" className="mt-2">
Aufgaben neu laden
</Button>
</div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@ export function usePollGalleryDelta(slug: string) {
const [newCount, setNewCount] = useState(0); const [newCount, setNewCount] = useState(0);
const latestAt = useRef<string | null>(null); const latestAt = useRef<string | null>(null);
const timer = useRef<number | null>(null); const timer = useRef<number | null>(null);
const [visible, setVisible] = useState(
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
);
async function fetchDelta() { async function fetchDelta() {
try { try {
@@ -75,16 +78,25 @@ export function usePollGalleryDelta(slug: string) {
} }
} }
useEffect(() => {
const onVis = () => setVisible(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, []);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
latestAt.current = null; latestAt.current = null;
setPhotos([]); setPhotos([]);
fetchDelta(); fetchDelta();
timer.current = window.setInterval(fetchDelta, 30_000); if (timer.current) window.clearInterval(timer.current);
// Poll less aggressively when hidden
const interval = visible ? 30_000 : 90_000;
timer.current = window.setInterval(fetchDelta, interval);
return () => { return () => {
if (timer.current) window.clearInterval(timer.current); if (timer.current) window.clearInterval(timer.current);
}; };
}, [slug]); }, [slug, visible]);
function acknowledgeNew() { setNewCount(0); } function acknowledgeNew() { setNewCount(0); }
return { loading, photos, newCount, acknowledgeNew }; return { loading, photos, newCount, acknowledgeNew };

View File

@@ -1,39 +1,67 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
type Stats = { onlineGuests: number; tasksSolved: number; latestPhotoAt?: string }; export type EventStats = {
onlineGuests: number;
tasksSolved: number;
latestPhotoAt: string | null;
};
export function usePollStats(slug: string) { type StatsResponse = {
const [data, setData] = useState<Stats | null>(null); online_guests?: number;
tasks_solved?: number;
latest_photo_at?: string;
};
export function usePollStats(slug: string | null | undefined) {
const [data, setData] = useState<EventStats>({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const timer = useRef<number | null>(null); const timer = useRef<number | null>(null);
const visible = typeof document !== 'undefined' ? document.visibilityState === 'visible' : true; const [visible, setVisible] = useState(
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
);
async function fetchOnce() { const canPoll = Boolean(slug);
async function fetchOnce(activeSlug: string) {
try { try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/stats`, { const res = await fetch(`/api/v1/events/${encodeURIComponent(activeSlug)}/stats`, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
if (res.status === 304) return; if (res.status === 304) return;
const json = await res.json(); const json: StatsResponse = await res.json();
setData({ onlineGuests: json.online_guests ?? 0, tasksSolved: json.tasks_solved ?? 0, latestPhotoAt: json.latest_photo_at }); setData({
onlineGuests: json.online_guests ?? 0,
tasksSolved: json.tasks_solved ?? 0,
latestPhotoAt: json.latest_photo_at ?? null,
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
useEffect(() => { useEffect(() => {
setLoading(true); const onVis = () => setVisible(document.visibilityState === 'visible');
fetchOnce(); document.addEventListener('visibilitychange', onVis);
function schedule() { return () => document.removeEventListener('visibilitychange', onVis);
if (!visible) return; }, []);
timer.current = window.setInterval(fetchOnce, 10_000);
useEffect(() => {
if (!canPoll) {
setLoading(false);
return;
}
setLoading(true);
const activeSlug = String(slug);
fetchOnce(activeSlug);
if (timer.current) window.clearInterval(timer.current);
if (visible) {
timer.current = window.setInterval(() => fetchOnce(activeSlug), 10_000);
} }
schedule();
return () => { return () => {
if (timer.current) window.clearInterval(timer.current); if (timer.current) window.clearInterval(timer.current);
}; };
}, [slug, visible]); }, [slug, visible, canPoll]);
return { loading, onlineGuests: data?.onlineGuests ?? 0, tasksSolved: data?.tasksSolved ?? 0 }; return { ...data, loading };
} }

View File

@@ -2,6 +2,7 @@ import { withStore } from './idb';
import { getDeviceId } from '../lib/device'; import { getDeviceId } from '../lib/device';
import { createUpload } from './xhr'; import { createUpload } from './xhr';
import { notify } from './notify'; import { notify } from './notify';
type SyncManager = { register(tag: string): Promise<void>; };
export type QueueItem = { export type QueueItem = {
id?: number; id?: number;
@@ -26,7 +27,10 @@ export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries'
}); });
// Register background sync if available // Register background sync if available
if ('serviceWorker' in navigator && 'SyncManager' in window) { if ('serviceWorker' in navigator && 'SyncManager' in window) {
try { const reg = await navigator.serviceWorker.ready; await reg.sync.register('upload-queue'); } catch {} try {
const reg = await navigator.serviceWorker.ready;
(reg as ServiceWorkerRegistration & { sync?: SyncManager }).sync?.register('upload-queue');
} catch {}
} }
} }

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { createBrowserRouter, Outlet, useParams } from 'react-router-dom'; import { createBrowserRouter, Outlet, useParams } from 'react-router-dom';
import Header from './components/Header'; import Header from './components/Header';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
import { EventStatsProvider } from './context/EventStatsContext';
import { GuestIdentityProvider } from './context/GuestIdentityContext';
import LandingPage from './pages/LandingPage'; import LandingPage from './pages/LandingPage';
import ProfileSetupPage from './pages/ProfileSetupPage'; import ProfileSetupPage from './pages/ProfileSetupPage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
@@ -19,20 +21,43 @@ import NotFoundPage from './pages/NotFoundPage';
function HomeLayout() { function HomeLayout() {
const { slug } = useParams(); const { slug } = useParams();
return (
<div className="pb-16"> if (!slug) {
{slug ? <Header slug={slug} /> : <Header title="Event" />} return (
<div className="px-4 py-3"> <div className="pb-16">
<Outlet /> <Header title="Event" />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div> </div>
<BottomNav /> );
</div> }
return (
<GuestIdentityProvider slug={slug}>
<EventStatsProvider slug={slug}>
<div className="pb-16">
<Header slug={slug} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
</EventStatsProvider>
</GuestIdentityProvider>
); );
} }
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ path: '/', element: <SimpleLayout title="Fotospiel"><LandingPage /></SimpleLayout> }, { path: '/', element: <SimpleLayout title="Fotospiel"><LandingPage /></SimpleLayout> },
{ path: '/setup', element: <SimpleLayout title="Profil"><ProfileSetupPage /></SimpleLayout> }, {
path: '/setup/:slug',
element: <SetupLayout />,
children: [
{ index: true, element: <ProfileSetupPage /> },
],
},
{ {
path: '/e/:slug', path: '/e/:slug',
element: <HomeLayout />, element: <HomeLayout />,
@@ -53,6 +78,21 @@ export const router = createBrowserRouter([
{ path: '*', element: <NotFoundPage /> }, { path: '*', element: <NotFoundPage /> },
]); ]);
function SetupLayout() {
const { slug } = useParams<{ slug: string }>();
if (!slug) return null;
return (
<GuestIdentityProvider slug={slug}>
<EventStatsProvider slug={slug}>
<div className="pb-0">
<Header slug={slug} />
<Outlet />
</div>
</EventStatsProvider>
</GuestIdentityProvider>
);
}
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) { function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="pb-16"> <div className="pb-16">
@@ -64,3 +104,4 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
</div> </div>
); );
} }

View File

@@ -0,0 +1,212 @@
import { getDeviceId } from '../lib/device';
export interface AchievementBadge {
id: string;
title: string;
description: string;
earned: boolean;
progress: number;
target: number;
}
export interface LeaderboardEntry {
guest: string;
photos: number;
likes: number;
}
export interface TopPhotoHighlight {
photoId: number;
guest: string;
likes: number;
task?: string | null;
createdAt: string;
thumbnail: string | null;
}
export interface TrendingEmotionHighlight {
emotionId: number;
name: string;
count: number;
}
export interface TimelinePoint {
date: string;
photos: number;
guests: number;
}
export interface FeedEntry {
photoId: number;
guest: string;
task?: string | null;
likes: number;
createdAt: string;
thumbnail: string | null;
}
export interface AchievementsPayload {
summary: {
totalPhotos: number;
uniqueGuests: number;
tasksSolved: number;
likesTotal: number;
};
personal: {
guestName: string;
photos: number;
tasks: number;
likes: number;
badges: AchievementBadge[];
} | null;
leaderboards: {
uploads: LeaderboardEntry[];
likes: LeaderboardEntry[];
};
highlights: {
topPhoto: TopPhotoHighlight | null;
trendingEmotion: TrendingEmotionHighlight | null;
timeline: TimelinePoint[];
};
feed: FeedEntry[];
}
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function safeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
export async function fetchAchievements(
slug: string,
guestName?: string,
signal?: AbortSignal
): Promise<AchievementsPayload> {
const params = new URLSearchParams();
if (guestName && guestName.trim().length > 0) {
params.set('guest_name', guestName.trim());
}
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/achievements?${params.toString()}`, {
method: 'GET',
headers: {
'X-Device-Id': getDeviceId(),
'Cache-Control': 'no-store',
},
signal,
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || 'Achievements request failed');
}
const json = await response.json();
const summary = json.summary ?? {};
const personalRaw = json.personal ?? null;
const leaderboards = json.leaderboards ?? {};
const highlights = json.highlights ?? {};
const feedRaw = Array.isArray(json.feed) ? json.feed : [];
const personal = personalRaw
? {
guestName: safeString(personalRaw.guest_name),
photos: toNumber(personalRaw.photos),
tasks: toNumber(personalRaw.tasks),
likes: toNumber(personalRaw.likes),
badges: Array.isArray(personalRaw.badges)
? personalRaw.badges.map((badge: any): AchievementBadge => ({
id: safeString(badge.id),
title: safeString(badge.title),
description: safeString(badge.description),
earned: Boolean(badge.earned),
progress: toNumber(badge.progress),
target: toNumber(badge.target, 1),
}))
: [],
}
: null;
const uploadsBoard = Array.isArray(leaderboards.uploads)
? leaderboards.uploads.map((row: any): LeaderboardEntry => ({
guest: safeString(row.guest),
photos: toNumber(row.photos),
likes: toNumber(row.likes),
}))
: [];
const likesBoard = Array.isArray(leaderboards.likes)
? leaderboards.likes.map((row: any): LeaderboardEntry => ({
guest: safeString(row.guest),
photos: toNumber(row.photos),
likes: toNumber(row.likes),
}))
: [];
const topPhotoRaw = highlights.top_photo ?? null;
const topPhoto = topPhotoRaw
? {
photoId: toNumber(topPhotoRaw.photo_id),
guest: safeString(topPhotoRaw.guest),
likes: toNumber(topPhotoRaw.likes),
task: topPhotoRaw.task ?? null,
createdAt: safeString(topPhotoRaw.created_at),
thumbnail: topPhotoRaw.thumbnail ? safeString(topPhotoRaw.thumbnail) : null,
}
: null;
const trendingRaw = highlights.trending_emotion ?? null;
const trendingEmotion = trendingRaw
? {
emotionId: toNumber(trendingRaw.emotion_id),
name: safeString(trendingRaw.name),
count: toNumber(trendingRaw.count),
}
: null;
const timeline = Array.isArray(highlights.timeline)
? highlights.timeline.map((row: any): TimelinePoint => ({
date: safeString(row.date),
photos: toNumber(row.photos),
guests: toNumber(row.guests),
}))
: [];
const feed = feedRaw.map((row: any): FeedEntry => ({
photoId: toNumber(row.photo_id),
guest: safeString(row.guest),
task: row.task ?? null,
likes: toNumber(row.likes),
createdAt: safeString(row.created_at),
thumbnail: row.thumbnail ? safeString(row.thumbnail) : null,
}));
return {
summary: {
totalPhotos: toNumber(summary.total_photos),
uniqueGuests: toNumber(summary.unique_guests),
tasksSolved: toNumber(summary.tasks_solved),
likesTotal: toNumber(summary.likes_total),
},
personal,
leaderboards: {
uploads: uploadsBoard,
likes: likesBoard,
},
highlights: {
topPhoto,
trendingEmotion,
timeline,
},
feed,
};
}

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ __('admin.shell.tenant_admin_title') }}</title> <title>{{ __('admin.shell.tenant_admin_title') }}</title>
@viteReactRefresh
@vite('resources/js/admin/main.tsx') @vite('resources/js/admin/main.tsx')
</head> </head>
<body> <body>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ config('app.name', 'Fotospiel') }}</title> <title>{{ config('app.name', 'Fotospiel') }}</title>
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
@viteReactRefresh
@vite('resources/js/guest/main.tsx') @vite('resources/js/guest/main.tsx')
</head> </head>
<body> <body>

View File

@@ -1,4 +1,4 @@
<?php <?php
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -16,6 +16,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::middleware('throttle:100,1')->group(function () { Route::middleware('throttle:100,1')->group(function () {
Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show'); Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show');
Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
Route::get('/events/{slug}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements');
Route::get('/events/{slug}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions'); Route::get('/events/{slug}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
Route::get('/events/{slug}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks'); Route::get('/events/{slug}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
Route::get('/events/{slug}/photos', [EventPublicController::class, 'photos'])->name('events.photos'); Route::get('/events/{slug}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
@@ -24,8 +25,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/events/{slug}/upload', [EventPublicController::class, 'upload'])->name('events.upload'); Route::post('/events/{slug}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
}); });
// Protected tenant API routes (require auth:sanctum + tenant middleware) // Protected tenant API routes (JWT tenants via OAuth guard)
Route::middleware(['auth:sanctum', \App\Http\Middleware\TenantTokenGuard::class, \App\Http\Middleware\TenantIsolation::class])->prefix('tenant')->group(function () { Route::middleware(['tenant.token', 'tenant.isolation'])->prefix('tenant')->group(function () {
Route::get('me', [OAuthController::class, 'me'])->name('tenant.me'); Route::get('me', [OAuthController::class, 'me'])->name('tenant.me');
// Events CRUD // Events CRUD
@@ -64,6 +65,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance'); Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance');
Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger'); Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger');
Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history'); Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history');
Route::post('purchase', [CreditController::class, 'purchase'])->name('tenant.credits.purchase');
Route::post('sync', [CreditController::class, 'sync'])->name('tenant.credits.sync');
}); });
}); });
}); });

View File

@@ -44,25 +44,6 @@ Route::get('/super-admin/templates/emotions.csv', function () {
return response()->stream($callback, 200, $headers); return response()->stream($callback, 200, $headers);
}); });
// Tenant Admin API (temporary token-based, no hardening)
Route::prefix('api/v1/tenant')->group(function () {
Route::post('/login', [\App\Http\Controllers\Api\TenantController::class, 'login']);
Route::middleware([\App\Http\Middleware\ApiTokenAuth::class])->group(function () {
Route::get('/me', [\App\Http\Controllers\Api\TenantController::class, 'me']);
Route::get('/events', [\App\Http\Controllers\Api\TenantController::class, 'events']);
Route::get('/events/{id}', [\App\Http\Controllers\Api\TenantController::class, 'showEvent']);
Route::post('/events', [\App\Http\Controllers\Api\TenantController::class, 'storeEvent']);
Route::put('/events/{id}', [\App\Http\Controllers\Api\TenantController::class, 'updateEvent']);
Route::post('/events/{id}/toggle', [\App\Http\Controllers\Api\TenantController::class, 'toggleEvent']);
Route::get('/events/{id}/photos', [\App\Http\Controllers\Api\TenantController::class, 'eventPhotos']);
Route::get('/events/{id}/stats', [\App\Http\Controllers\Api\TenantController::class, 'eventStats']);
Route::post('/events/{id}/invites', [\App\Http\Controllers\Api\TenantController::class, 'createInvite']);
Route::post('/photos/{id}/feature', [\App\Http\Controllers\Api\TenantController::class, 'featurePhoto']);
Route::post('/photos/{id}/unfeature', [\App\Http\Controllers\Api\TenantController::class, 'unfeaturePhoto']);
Route::delete('/photos/{id}', [\App\Http\Controllers\Api\TenantController::class, 'deletePhoto']);
});
});
// Tenant Admin PWA shell // Tenant Admin PWA shell
Route::view('/admin/{any?}', 'admin')->where('any', '.*'); Route::view('/admin/{any?}', 'admin')->where('any', '.*');
Route::get('/admin/qr', [\App\Http\Controllers\Admin\QrController::class, 'png']); Route::get('/admin/qr', [\App\Http\Controllers\Admin\QrController::class, 'png']);

View File

@@ -0,0 +1,143 @@
<?php
namespace Tests\Feature;
use App\Models\OAuthClient;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class OAuthFlowTest extends TestCase
{
use RefreshDatabase;
private const PUBLIC_KEY = <<<KEY
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlrZWbp/7pXo83BIJX3v/
9f/51fxYFGZnZz9diqHkiOtDjggNdwze0LXruVeVb8YsaTI68RclgYCcsE4haTCG
LlTivKFJL2O10IEzswjjD08MsanHer3xZRO6VZ7JLXmBNKp5C71zfFf8AhMnQ+Y6
uGQ3wMOT6PWAiAmVBVYC8+KQsqyOkDu58bamhGGOrDsdWvrfDgRU1w8dxbgFYALQ
v1pVVmYT9oBxZcS5FlT8auf8zLcHXEl6S7X61ZPd/GTWT5htdSiJyXfSa/xM7bJP
CCv+mK6Gd5+1UG3RHGuwoi8Rch2O8PMglZqF6ybv/w836jUQKPl+sndePNN3soKQ
5wIDAQAB
-----END PUBLIC KEY-----
KEY;
private const PRIVATE_KEY = <<<KEY
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCWtlZun/ulejzc
Eglfe//1//nV/FgUZmdnP12KoeSI60OOCA13DN7Qteu5V5VvxixpMjrxFyWBgJyw
TiFpMIYuVOK8oUkvY7XQgTOzCOMPTwyxqcd6vfFlE7pVnskteYE0qnkLvXN8V/wC
EydD5jq4ZDfAw5Po9YCICZUFVgLz4pCyrI6QO7nxtqaEYY6sOx1a+t8OBFTXDx3F
uAVgAtC/WlVWZhP2gHFlxLkWVPxq5/zMtwdcSXpLtfrVk938ZNZPmG11KInJd9Jr
/Eztsk8IK/6YroZ3n7VQbdEca7CiLxFyHY7w8yCVmoXrJu//DzfqNRAo+X6yd148
03eygpDnAgMBAAECggEAFoldk11I/A2zXBU2YZjhRZ/pdB4v7Z0CiWXoTvq2eeL0
TyDVIqBCEWOixCxcpEI2EeT4+2RCr4LT62lDhb9D0VnQLfTQRM3cOjmXyYXirj9b
3pVMxwXwOvUgP/1mh+5La9yyDRdfVZCylnzWukiLL1eNHr4gOA2+EpmcNxgNiPp1
Z8USUp2kmSZMPmQDkGEAJnrqmW7LyBvda3yuW557WtpaQlHTprvNQdBIUoFhLiiS
HnV9kZfQHM3BdM06zx8c7W6sbVavLQlaD0mhM6Z7o7566pq1JKScjhfoGcZRTmLs
kshQVSf38ayhAz8CikWiJgqFJigIZI0bR9fROOy+wQKBgQDOWjVRq8Ql+Eu0so/B
3hS1TGaBOFe5vymeX+hnC87Zu7yVsj96mhmofnlTJdbSZLHfO631XD9O3qCcYzuK
1PLzOvO38ZVZLq/CkiwkC4qfGVQb3/8v0QyIXCKhMrwkwuL6AYMjQi6vd/+4vp2C
5EJefbNBfdvsC90t84wxqBpIDQKBgQC6+Rs7cBD9VOAKkNH1O4k9cE1JCDX6aqlg
RtO/93+kbqxz3llvIebI9z3CPE7Wp0n2GEFjvDCTy5kST7BQvdwm4VlthSpfhx+l
4ahw1+xbB3KQxemmf3MroTZWHLfTOGvHdei05EIdRZv8Mpi9UcHd7OhVO82SUnLn
pBqGLZGrwwKBgB2FiltE16sW+r2/ThHOU+gcJg4WoXZRgwLFddpINi+wTCqedbZ0
lXcloPXkU/eFsGzffOO9btE5yICXMc2K6bcil/uY9GTt6PdNMkN14z8fwIi8YyXU
Ipbfl5S4TXJ070QVM024CjXQVSV5H8+6GESsdxjHiM8cY2hPj58LDbeBAoGAfd5r
FcVoupJjzNkXbwboagLrFGpBpFYfth+YN1hPhou27r3V6TmiWtIOsm7VCC5QXSqR
AqpS7XwXjTs2T/Swe0AjatZF409c39gdA/JoPBO0bX++voZ4Kvv5T1k/6yLFc96N
jRFI7NnKm6oYJwMeBt+QvKhoyMNWdViFPqT4tu8CgYEAmcInq55jIJOr7GNvf6jV
wojrBxhEGOF8U8YqX6FgVEmVDkEOer3mFDnkZT/S2IFjH4eruo/ZTFFtyw9K9JGd
06FINYtK/H91SdcOJHuWdELuTQw0+Jtr47tSUlp1c3L0J7Mt1Sqqzg8lLoLYPcLJ
d7faJuYR8uKalWG3ZimbGNo=
-----END PRIVATE KEY-----
KEY;
protected function setUp(): void
{
parent::setUp();
file_put_contents(storage_path('app/public.key'), self::PUBLIC_KEY);
file_put_contents(storage_path('app/private.key'), self::PRIVATE_KEY);
}
public function test_authorization_code_flow_and_refresh(): void
{
$tenant = Tenant::factory()->create([
'slug' => 'test-tenant',
]);
OAuthClient::create([
'id' => (string) Str::uuid(),
'client_id' => 'tenant-admin-app',
'tenant_id' => $tenant->id,
'redirect_uris' => ['http://localhost/callback'],
'scopes' => ['tenant:read', 'tenant:write'],
'is_active' => true,
]);
$codeVerifier = 'unit-test-code-verifier-1234567890';
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
$state = Str::random(10);
$response = $this->get('/api/v1/oauth/authorize?' . http_build_query([
'client_id' => 'tenant-admin-app',
'redirect_uri' => 'http://localhost/callback',
'response_type' => 'code',
'scope' => 'tenant:read tenant:write',
'state' => $state,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
]));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertNotNull($location);
$query = [];
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
$authorizationCode = $query['code'] ?? null;
$this->assertNotNull($authorizationCode, 'Authorization code should be present');
$this->assertEquals($state, $query['state'] ?? null);
$tokenResponse = $this->post('/api/v1/oauth/token', [
'grant_type' => 'authorization_code',
'code' => $authorizationCode,
'client_id' => 'tenant-admin-app',
'redirect_uri' => 'http://localhost/callback',
'code_verifier' => $codeVerifier,
]);
$tokenResponse->assertOk();
$tokenData = $tokenResponse->json();
$this->assertArrayHasKey('access_token', $tokenData);
$this->assertArrayHasKey('refresh_token', $tokenData);
$this->assertSame('Bearer', $tokenData['token_type']);
$meResponse = $this->get('/api/v1/tenant/me', [
'Authorization' => 'Bearer ' . $tokenData['access_token'],
]);
$meResponse->assertOk();
$meResponse->assertJsonFragment([
'tenant_id' => $tenant->id,
'name' => $tenant->name,
]);
$refreshResponse = $this->post('/api/v1/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $tokenData['refresh_token'],
'client_id' => 'tenant-admin-app',
]);
$refreshResponse->assertOk();
$refreshData = $refreshResponse->json();
$this->assertArrayHasKey('access_token', $refreshData);
$this->assertArrayHasKey('refresh_token', $refreshData);
$this->assertNotEquals($refreshData['access_token'], $tokenData['access_token']);
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace Tests\Feature;
use App\Models\OAuthClient;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class TenantCreditsTest extends TestCase
{
use RefreshDatabase;
private const PUBLIC_KEY = <<<KEY
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlrZWbp/7pXo83BIJX3v/
9f/51fxYFGZnZz9diqHkiOtDjggNdwze0LXruVeVb8YsaTI68RclgYCcsE4haTCG
LlTivKFJL2O10IEzswjjD08MsanHer3xZRO6VZ7JLXmBNKp5C71zfFf8AhMnQ+Y6
uGQ3wMOT6PWAiAmVBVYC8+KQsqyOkDu58bamhGGOrDsdWvrfDgRU1w8dxbgFYALQ
v1pVVmYT9oBxZcS5FlT8auf8zLcHXEl6S7X61ZPd/GTWT5htdSiJyXfSa/xM7bJP
CCv+mK6Gd5+1UG3RHGuwoi8Rch2O8PMglZqF6ybv/w836jUQKPl+sndePNN3soKQ
5wIDAQAB
-----END PUBLIC KEY-----
KEY;
private const PRIVATE_KEY = <<<KEY
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCWtlZun/ulejzc
Eglfe//1//nV/FgUZmdnP12KoeSI60OOCA13DN7Qteu5V5VvxixpMjrxFyWBgJyw
TiFpMIYuVOK8oUkvY7XQgTOzCOMPTwyxqcd6vfFlE7pVnskteYE0qnkLvXN8V/wC
EydD5jq4ZDfAw5Po9YCICZUFVgLz4pCyrI6QO7nxtqaEYY6sOx1a+t8OBFTXDx3F
uAVgAtC/WlVWZhP2gHFlxLkWVPxq5/zMtwdcSXpLtfrVk938ZNZPmG11KInJd9Jr
/Eztsk8IK/6YroZ3n7VQbdEca7CiLxFyHY7w8yCVmoXrJu//DzfqNRAo+X6yd148
03eygpDnAgMBAAECggEAFoldk11I/A2zXBU2YZjhRZ/pdB4v7Z0CiWXoTvq2eeL0
TyDVIqBCEWOixCxcpEI2EeT4+2RCr4LT62lDhb9D0VnQLfTQRM3cOjmXyYXirj9b
3pVMxwXwOvUgP/1mh+5La9yyDRdfVZCylnzWukiLL1eNHr4gOA2+EpmcNxgNiPp1
Z8USUp2kmSZMPmQDkGEAJnrqmW7LyBvda3yuW557WtpaQlHTprvNQdBIUoFhLiiS
HnV9kZfQHM3BdM06zx8c7W6sbVavLQlaD0mhM6Z7o7566pq1JKScjhfoGcZRTmLs
kshQVSf38ayhAz8CikWiJgqFJigIZI0bR9fROOy+wQKBgQDOWjVRq8Ql+Eu0so/B
3hS1TGaBOFe5vymeX+hnC87Zu7yVsj96mhmofnlTJdbSZLHfO631XD9O3qCcYzuK
1PLzOvO38ZVZLq/CkiwkC4qfGVQb3/8v0QyIXCKhMrwkwuL6AYMjQi6vd/+4vp2C
5EJefbNBfdvsC90t84wxqBpIDQKBgQC6+Rs7cBD9VOAKkNH1O4k9cE1JCDX6aqlg
RtO/93+kbqxz3llvIebI9z3CPE7Wp0n2GEFjvDCTy5kST7BQvdwm4VlthSpfhx+l
4ahw1+xbB3KQxemmf3MroTZWHLfTOGvHdei05EIdRZv8Mpi9UcHd7OhVO82SUnLn
pBqGLZGrwwKBgB2FiltE16sW+r2/ThHOU+gcJg4WoXZRgwLFddpINi+wTCqedbZ0
lXcloPXkU/eFsGzffOO9btE5yICXMc2K6bcil/uY9GTt6PdNMkN14z8fwIi8YyXU
Ipbfl5S4TXJ070QVM024CjXQVSV5H8+6GESsdxjHiM8cY2hPj58LDbeBAoGAfd5r
FcVoupJjzNkXbwboagLrFGpBpFYfth+YN1hPhou27r3V6TmiWtIOsm7VCC5QXSqR
AqpS7XwXjTs2T/Swe0AjatZF409c39gdA/JoPBO0bX++voZ4Kvv5T1k/6yLFc96N
jRFI7NnKm6oYJwMeBt+QvKhoyMNWdViFPqT4tu8CgYEAmcInq55jIJOr7GNvf6jV
wojrBxhEGOF8U8YqX6FgVEmVDkEOer3mFDnkZT/S2IFjH4eruo/ZTFFtyw9K9JGd
06FINYtK/H91SdcOJHuWdELuTQw0+Jtr47tSUlp1c3L0J7Mt1Sqqzg8lLoLYPcLJ
d7faJuYR8uKalWG3ZimbGNo=
-----END PRIVATE KEY-----
KEY;
protected function setUp(): void
{
parent::setUp();
file_put_contents(storage_path('app/public.key'), self::PUBLIC_KEY);
file_put_contents(storage_path('app/private.key'), self::PRIVATE_KEY);
}
public function test_tenant_can_retrieve_balance_and_purchase_credits(): void
{
$tenant = Tenant::factory()->create([
'slug' => 'credits-tenant',
'event_credits_balance' => 0,
]);
$client = OAuthClient::create([
'id' => (string) Str::uuid(),
'client_id' => 'tenant-admin-app',
'tenant_id' => $tenant->id,
'redirect_uris' => ['http://localhost/callback'],
'scopes' => ['tenant:read', 'tenant:write'],
'is_active' => true,
]);
[$accessToken] = $this->obtainTokens($client);
$headers = [
'Authorization' => 'Bearer '.$accessToken,
];
$balanceResponse = $this->withHeaders($headers)
->getJson('/api/v1/tenant/credits/balance');
$balanceResponse->assertOk()
->assertJsonStructure(['balance', 'free_event_granted_at']);
$purchaseResponse = $this->withHeaders($headers)
->postJson('/api/v1/tenant/credits/purchase', [
'package_id' => 'event_starter',
'credits_added' => 5,
'platform' => 'capacitor',
'transaction_id' => 'txn_test_123',
'subscription_active' => false,
]);
$purchaseResponse->assertCreated()
->assertJsonStructure(['message', 'balance', 'subscription_active']);
$tenant->refresh();
$this->assertSame(5, $tenant->event_credits_balance);
$this->assertDatabaseHas('event_purchases', [
'tenant_id' => $tenant->id,
'events_purchased' => 5,
'external_receipt_id' => 'txn_test_123',
]);
$this->assertDatabaseHas('event_credits_ledger', [
'tenant_id' => $tenant->id,
'delta' => 5,
'reason' => 'purchase',
]);
$syncResponse = $this->withHeaders($headers)
->postJson('/api/v1/tenant/credits/sync', [
'balance' => $tenant->event_credits_balance,
'subscription_active' => false,
'last_sync' => now()->toIso8601String(),
]);
$syncResponse->assertOk()
->assertJsonStructure(['balance', 'subscription_active', 'server_time']);
}
private function obtainTokens(OAuthClient $client): array
{
$codeVerifier = 'tenant-credits-code-verifier-1234567890';
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
$state = Str::random(10);
$response = $this->get('/api/v1/oauth/authorize?' . http_build_query([
'client_id' => $client->client_id,
'redirect_uri' => 'http://localhost/callback',
'response_type' => 'code',
'scope' => 'tenant:read tenant:write',
'state' => $state,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
]));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertNotNull($location);
$query = [];
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
$authorizationCode = $query['code'] ?? null;
$this->assertNotNull($authorizationCode, 'Authorization code should be present');
$tokenResponse = $this->post('/api/v1/oauth/token', [
'grant_type' => 'authorization_code',
'code' => $authorizationCode,
'client_id' => $client->client_id,
'redirect_uri' => 'http://localhost/callback',
'code_verifier' => $codeVerifier,
]);
$tokenResponse->assertOk();
return [
$tokenResponse->json('access_token'),
$tokenResponse->json('refresh_token'),
];
}
}

View File

@@ -0,0 +1,52 @@
import { test, expect } from '@playwright/test';
test.describe('Guest Profile Flow', () => {
test('should require name setup on first event join and persist it', async ({ page }) => {
// Assume Vite dev server is running on localhost:5173
await page.goto('http://localhost:5173/');
// Enter event slug manually
await page.fill('input[placeholder*="Event-Code"]', 'test-event');
await page.click('button:has-text("Event beitreten")');
// Should redirect to setup if no name
await expect(page).toHaveURL(/.*\/e\/test-event\/setup/);
// Fill name and submit
await page.fill('input[placeholder*="Dein Name"]', 'Test User');
await page.click('button:has-text("LET\'S GO! ✨")');
// Should navigate to home
await expect(page).toHaveURL(/.*\/e\/test-event$/);
// Check localStorage
const storedName = await page.evaluate(() => localStorage.getItem('guestName_test-event'));
expect(storedName).toBe('Test User');
// Reload to test persistence - should stay on home, not redirect to setup
await page.reload();
await expect(page).toHaveURL(/.*\/e\/test-event$/);
// Re-nav to landing and join again - should go directly to home
await page.goto('http://localhost:5173/');
await page.fill('input[placeholder*="Event-Code"]', 'test-event');
await page.click('button:has-text("Event beitreten")');
await expect(page).toHaveURL(/.*\/e\/test-event$/);
});
test('should go directly to home if name already stored', async ({ page }) => {
// Pre-set name in localStorage
await page.addInitScript(() => {
localStorage.setItem('guestName_test-event', 'Existing User');
});
await page.goto('http://localhost:5173/');
// Join
await page.fill('input[placeholder*="Event-Code"]', 'test-event');
await page.click('button:has-text("Event beitreten")');
// Should go directly to home
await expect(page).toHaveURL(/.*\/e\/test-event$/);
});
});

View File

@@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
test('OAuth Flow for tenant-admin-app', async ({ page }) => {
const code_challenge = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const code_verifier = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
const redirect_uri = 'http://localhost:8000/auth/callback';
const state = 'teststate';
const scope = 'tenant:read tenant:write tenant:admin';
const authorizeUrl = `/api/v1/oauth/authorize?response_type=code&client_id=tenant-admin-app&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${encodeURIComponent(scope)}&code_challenge=${code_challenge}&code_challenge_method=S256&state=${state}`;
// Navigate to authorize - should immediately redirect to callback
await page.goto(authorizeUrl);
await page.waitForLoadState('networkidle');
// Log response if no redirect
const currentUrl = page.url();
if (currentUrl.includes('/authorize')) {
const response = await page.content();
console.log('No redirect, response:', response.substring(0, 500)); // First 500 chars
}
// Wait for redirect to callback and parse params
await expect(page).toHaveURL(new RegExp(`${redirect_uri}\\?.*`));
const urlObj = new URL(currentUrl);
const code = urlObj.searchParams.get('code') || '';
const receivedState = urlObj.searchParams.get('state') || '';
expect(receivedState).toBe(state);
expect(code).not.toBeNull();
console.log('Authorization code:', code);
// Token exchange via fetch
const tokenParams = {
code: code!,
redirect_uri,
code_verifier
};
const tokenResponse = await page.evaluate(async (params) => {
const response = await fetch('/api/v1/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'tenant-admin-app',
code: params.code,
redirect_uri: params.redirect_uri,
code_verifier: params.code_verifier,
}).toString(),
});
return await response.json();
}, tokenParams);
console.log('Token response:', tokenResponse);
expect(tokenResponse.access_token).toBeTruthy();
const accessToken = tokenResponse.access_token;
// Call /tenant/me with token
const meResponse = await page.evaluate(async (token) => {
const response = await fetch('/api/v1/tenant/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
},
});
return await response.json();
}, accessToken);
console.log('/tenant/me response:', meResponse);
expect(meResponse).toHaveProperty('id');
expect(meResponse.email).toBe('demo@example.com');
});

View File

@@ -19,19 +19,53 @@ export default defineConfig({
origin: devServerOrigin, origin: devServerOrigin,
hmr: { hmr: {
host: parsedOrigin.hostname, host: parsedOrigin.hostname,
protocol: parsedOrigin.protocol.replace(':', ''), protocol: parsedOrigin.protocol.replace(':','') as 'http' | 'https',
port: hmrPort, clientPort: hmrPort,
},
fs: {
strict: true,
// Erlaube nur das App-Package (ggf. Pfade anpassen)
allow: [__dirname],
}, },
cors: { cors: {
origin: appUrl, origin: appUrl,
credentials: true, credentials: true,
}, },
watch: {
// WENIGER ist mehr: Alles ausklammern, was nicht für HMR nötig ist
ignored: [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
'**/.next/**',
'**/coverage/**',
'**/.cache/**',
// Laravel-spezifisch
'**/public/build/**',
'**/storage/**',
'**/vendor/**',
'**/bootstrap/cache/**',
// Monorepo-Nachbarn
'../**/node_modules/**',
'../**/dist/**',
'../**/build/**',
'../**/coverage/**',
],
// Falls ihr auf gemounteten FS seid und Events fehlen:
// usePolling: true, interval: 500,
},
}, },
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.tsx'], input: ['resources/css/app.css', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.tsx'],
ssr: 'resources/js/ssr.tsx', ssr: 'resources/js/ssr.tsx',
refresh: true, refresh: [
'resources/views/**/*.blade.php',
'resources/lang/**/*.php',
'app/Http/Livewire/**', // falls genutzt
// NICHT beobachten: storage/logs, vendor, public/build, etc.
],
}), }),
react(), react(),
tailwindcss(), tailwindcss(),
@@ -42,4 +76,20 @@ export default defineConfig({
esbuild: { esbuild: {
jsx: 'automatic', jsx: 'automatic',
}, },
optimizeDeps: {
// Bei großen Monorepos hilfreich:
entries: ['resources/js/**/*'],
exclude: [
// füge notfalls große/selten genutzte Pakete hinzu
],
},
// Build-Optionen wirken vor allem bei `vite build`, schaden aber nicht:
build: {
sourcemap: false,
target: 'es2020',
rollupOptions: {
// keine externen Monster-Globs
},
},
}); });