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
docker
podman-compose.dev.yml
test-results

View File

@@ -279,7 +279,6 @@ class EventPublicController extends BaseController
'photos.likes_count',
'photos.emotion_id',
'photos.task_id',
'photos.created_at',
'photos.guest_name',
'tasks.title as task_title'
])
@@ -503,4 +502,220 @@ class EventPublicController extends BaseController
return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
}
public function achievements(Request $request, string $slug)
{
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
}
$eventId = $event->id;
$locale = $request->query('locale', 'de');
$totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count();
$uniqueGuests = (int) DB::table('photos')->where('event_id', $eventId)->distinct('guest_name')->count('guest_name');
$tasksSolved = (int) DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
$likesTotal = (int) DB::table('photos')->where('event_id', $eventId)->sum('likes_count');
$summary = [
'total_photos' => $totalPhotos,
'unique_guests' => $uniqueGuests,
'tasks_solved' => $tasksSolved,
'likes_total' => $likesTotal,
];
$guestNameParam = trim((string) $request->query('guest_name', ''));
$deviceIdHeader = (string) $request->headers->get('X-Device-Id', '');
$deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120);
$candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId;
$guestIdentifier = $candidate !== '' ? substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120) : null;
$personal = null;
if ($guestIdentifier) {
$personalPhotos = (int) DB::table('photos')
->where('event_id', $eventId)
->where('guest_name', $guestIdentifier)
->count();
$personalTasks = (int) DB::table('photos')
->where('event_id', $eventId)
->where('guest_name', $guestIdentifier)
->whereNotNull('task_id')
->distinct('task_id')
->count('task_id');
$personalLikes = (int) DB::table('photos')
->where('event_id', $eventId)
->where('guest_name', $guestIdentifier)
->sum('likes_count');
$badgeBlueprints = [
['id' => 'first_photo', 'title' => 'Erstes Foto', 'description' => 'Lade dein erstes Foto hoch.', 'target' => 1, 'metric' => 'photos'],
['id' => 'five_photos', 'title' => 'Fotoflair', 'description' => 'Fuenf Fotos hochgeladen.', 'target' => 5, 'metric' => 'photos'],
['id' => 'first_task', 'title' => 'Aufgabenstarter', 'description' => 'Erste Aufgabe erfuellt.', 'target' => 1, 'metric' => 'tasks'],
['id' => 'task_master', 'title' => 'Aufgabenmeister', 'description' => 'Fuenf Aufgaben erfuellt.', 'target' => 5, 'metric' => 'tasks'],
['id' => 'crowd_pleaser', 'title' => 'Publikumsliebling', 'description' => 'Sammle zwanzig Likes.', 'target' => 20, 'metric' => 'likes'],
];
$personalBadges = [];
foreach ($badgeBlueprints as $badge) {
$value = 0;
if ($badge['metric'] === 'photos') {
$value = $personalPhotos;
} elseif ($badge['metric'] === 'tasks') {
$value = $personalTasks;
} else {
$value = $personalLikes;
}
$personalBadges[] = [
'id' => $badge['id'],
'title' => $badge['title'],
'description' => $badge['description'],
'earned' => $value >= $badge['target'],
'progress' => $value,
'target' => $badge['target'],
];
}
$personal = [
'guest_name' => $guestIdentifier,
'photos' => $personalPhotos,
'tasks' => $personalTasks,
'likes' => $personalLikes,
'badges' => $personalBadges,
];
}
$topUploads = DB::table('photos')
->select('guest_name', DB::raw('COUNT(*) as total'), DB::raw('SUM(likes_count) as likes'))
->where('event_id', $eventId)
->groupBy('guest_name')
->orderByDesc('total')
->limit(5)
->get()
->map(fn ($row) => [
'guest' => $row->guest_name,
'photos' => (int) $row->total,
'likes' => (int) $row->likes,
])
->values();
$topLikes = DB::table('photos')
->select('guest_name', DB::raw('SUM(likes_count) as likes'), DB::raw('COUNT(*) as total'))
->where('event_id', $eventId)
->groupBy('guest_name')
->orderByDesc('likes')
->limit(5)
->get()
->map(fn ($row) => [
'guest' => $row->guest_name,
'likes' => (int) $row->likes,
'photos' => (int) $row->total,
])
->values();
$topPhotoRow = DB::table('photos')
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
->where('photos.event_id', $eventId)
->orderByDesc('photos.likes_count')
->orderByDesc('photos.created_at')
->select([
'photos.id',
'photos.guest_name',
'photos.likes_count',
'photos.file_path',
'photos.thumbnail_path',
'photos.created_at',
'tasks.title as task_title',
])
->first();
$topPhoto = $topPhotoRow ? [
'photo_id' => (int) $topPhotoRow->id,
'guest' => $topPhotoRow->guest_name,
'likes' => (int) $topPhotoRow->likes_count,
'task' => $topPhotoRow->task_title,
'created_at' => $topPhotoRow->created_at,
'thumbnail' => $this->toPublicUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path),
] : null;
$trendingEmotionRow = DB::table('photos')
->join('emotions', 'photos.emotion_id', '=', 'emotions.id')
->where('photos.event_id', $eventId)
->select('emotions.id', 'emotions.name', DB::raw('COUNT(*) as total'))
->groupBy('emotions.id', 'emotions.name')
->orderByDesc('total')
->first();
$trendingEmotion = $trendingEmotionRow ? [
'emotion_id' => (int) $trendingEmotionRow->id,
'name' => $this->getLocalized($trendingEmotionRow->name, $locale, $trendingEmotionRow->name),
'count' => (int) $trendingEmotionRow->total,
] : null;
$timeline = DB::table('photos')
->where('event_id', $eventId)
->selectRaw('DATE(created_at) as day, COUNT(*) as photos, COUNT(DISTINCT guest_name) as guests')
->groupBy('day')
->orderBy('day')
->limit(14)
->get()
->map(fn ($row) => [
'date' => $row->day,
'photos' => (int) $row->photos,
'guests' => (int) $row->guests,
])
->values();
$feed = DB::table('photos')
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
->where('photos.event_id', $eventId)
->orderByDesc('photos.created_at')
->limit(12)
->get([
'photos.id',
'photos.guest_name',
'photos.thumbnail_path',
'photos.file_path',
'photos.likes_count',
'photos.created_at',
'tasks.title as task_title',
])
->map(fn ($row) => [
'photo_id' => (int) $row->id,
'guest' => $row->guest_name,
'task' => $row->task_title,
'likes' => (int) $row->likes_count,
'created_at' => $row->created_at,
'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path),
])
->values();
$payload = [
'summary' => $summary,
'personal' => $personal,
'leaderboards' => [
'uploads' => $topUploads,
'likes' => $topLikes,
],
'highlights' => [
'top_photo' => $topPhoto,
'trending_emotion' => $trendingEmotion,
'timeline' => $timeline,
],
'feed' => $feed,
];
$etag = sha1(json_encode($payload));
$ifNoneMatch = $request->headers->get('If-None-Match');
if ($ifNoneMatch && $ifNoneMatch === $etag) {
return response('', 304);
}
return response()->json($payload)
->header('Cache-Control', 'no-store')
->header('ETag', $etag);
}
}

View File

@@ -108,7 +108,7 @@ class PhotoController extends Controller
'width' => null, // To be filled by image processing
'height' => null,
'status' => 'pending', // Requires moderation
'uploader_id' => $request->user()->id ?? null,
'uploader_id' => null,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
@@ -170,8 +170,8 @@ class PhotoController extends Controller
]);
// Only tenant admins can moderate
if (isset($validated['status']) && $request->user()->role !== 'admin') {
return response()->json(['error' => 'Insufficient permissions for moderation'], 403);
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
return response()->json(['error' => 'Insufficient scopes'], 403);
}
$photo->update($validated);
@@ -243,7 +243,7 @@ class PhotoController extends Controller
'status' => 'approved',
'moderation_notes' => $request->moderation_notes,
'moderated_at' => now(),
'moderated_by' => $request->user()->id,
'moderated_by' => null,
]);
// Load approved photos for response
@@ -288,7 +288,7 @@ class PhotoController extends Controller
'status' => 'rejected',
'moderation_notes' => $request->moderation_notes,
'moderated_at' => now(),
'moderated_by' => $request->user()->id,
'moderated_by' => null,
]);
// Optionally delete rejected photos from storage
@@ -369,6 +369,17 @@ class PhotoController extends Controller
]);
}
private function tokenHasScope(Request $request, string $scope): bool
{
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
if (! is_array($scopes)) {
$scopes = array_values(array_filter(explode(' ', (string) $scopes)));
}
return in_array($scope, $scopes, true);
}
/**
* Generate presigned S3 URL for direct upload (alternative to local storage)
*/
@@ -467,4 +478,10 @@ class PhotoController extends Controller
'status' => 'pending',
], 201);
}
}
}

View File

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

View File

@@ -8,18 +8,15 @@ use App\Http\Resources\Tenant\EventPurchaseResource;
use App\Models\EventCreditsLedger;
use App\Models\EventPurchase;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CreditController extends Controller
{
public function balance(Request $request)
public function balance(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
$tenant = $this->resolveTenant($request);
return response()->json([
'balance' => $tenant->event_credits_balance,
@@ -29,14 +26,10 @@ class CreditController extends Controller
public function ledger(Request $request)
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = $this->resolveTenant($request);
$tenant = Tenant::findOrFail($user->tenant_id);
$ledgers = EventCreditsLedger::where('tenant_id', $tenant->id)
->orderBy('created_at', 'desc')
->orderByDesc('created_at')
->paginate(20);
return CreditLedgerResource::collection($ledgers);
@@ -44,16 +37,100 @@ class CreditController extends Controller
public function history(Request $request)
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = $this->resolveTenant($request);
$tenant = Tenant::findOrFail($user->tenant_id);
$purchases = EventPurchase::where('tenant_id', $tenant->id)
->orderBy('purchased_at', 'desc')
->orderByDesc('purchased_at')
->paginate(20);
return EventPurchaseResource::collection($purchases);
}
}
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;
use App\Models\Event;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Models\Tenant;
use App\Models\Event;
class CreditCheckMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) {
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
$tenant = Tenant::findOrFail($user->tenant_id);
$tenant = $this->resolveTenant($request);
if ($tenant->event_credits_balance < 1) {
return response()->json(['message' => 'Insufficient event credits'], 422);
@@ -30,10 +25,28 @@ class CreditCheckMiddleware
$event = Event::where('slug', $eventSlug)->firstOrFail();
$tenant = $event->tenant;
// For update, no credit check needed (already consumed on create)
$request->merge(['tenant' => $tenant]);
}
return $next($request);
}
}
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,8 +27,12 @@ class TenantIsolation
}
// Set tenant context for query scoping
DB::statement("SET @tenant_id = ?", [$tenantId]);
$connection = DB::connection();
if (in_array($connection->getDriverName(), ['mysql', 'mariadb'])) {
$connection->statement('SET @tenant_id = ?', [$tenantId]);
}
// Add tenant context to request for easy access in controllers
$request->attributes->set('current_tenant_id', $tenantId);
@@ -58,4 +62,4 @@ class TenantIsolation
// 4. For tenant-specific resources, use token tenant_id
return null;
}
}
}

View File

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

View File

@@ -8,9 +8,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OAuthCode extends Model
{
protected $table = 'oauth_codes';
public $timestamps = false;
protected $guarded = [];
protected $fillable = [
'id',
'client_id',
@@ -22,22 +24,22 @@ class OAuthCode extends Model
'scope',
'expires_at',
];
protected $casts = [
'expires_at' => 'datetime',
'created_at' => 'datetime',
];
public function client(): BelongsTo
{
return $this->belongsTo(OAuthClient::class, 'client_id', 'client_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at < now();

View File

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

View File

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

View File

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

View File

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

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;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
@@ -31,6 +29,14 @@ class DatabaseSeeder extends Seeder
$this->call([
SuperAdminSeeder::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.
@@ -8,25 +8,26 @@ Routing Principles
- Prefer modal routes (photo detail) layered over the gallery.
Route Map (proposed)
- `/` Landing (QR/PIN input; deep-link handler)
- `/setup` Profile Setup (name/avatar; skippable)
- `/e/:slug` Home/Feed (default gallery view + info bar)
- `/e/:slug/tasks` Task Picker (random/emotion)
- `/e/:slug/tasks/:taskId` Task Detail (card)
- `/e/:slug/upload` Upload Picker (camera/library + tagging)
- `/e/:slug/queue` Upload Queue (progress/retry)
- `/e/:slug/gallery` Gallery index (alias of Home or dedicated page)
- `/e/:slug/photo/:photoId` Photo Lightbox (modal over gallery)
- `/e/:slug/achievements` Achievements (optional)
- `/e/:slug/slideshow` Slideshow (optional, read-only)
- `/settings` — Settings (language/theme/cache/legal)
- `/legal/:page` — Legal pages (imprint/privacy/terms)
- `*` — NotFound
- `/` — Landing (QR/PIN input; deep-link handler)
- `/setup` — Profile Setup (name/avatar; skippable)
- `/e/:slug` — Home/Feed (default gallery view + info bar)
- `/e/:slug/tasks` — Task Picker (random/emotion)
- `/e/:slug/tasks/:taskId` — Task Detail (card)
- `/e/:slug/upload` — Upload Picker (camera/library + tagging)
- `/e/:slug/queue` — Upload Queue (progress/retry)
- `/e/:slug/gallery` — Gallery index (alias of Home or dedicated page)
- `/e/:slug/photo/:photoId` — Photo Lightbox (modal over gallery)
- `/e/:slug/achievements` — Achievements (optional)
- `/e/:slug/slideshow` — Slideshow (optional, read-only)
- `/legal/:page` — Legal pages (imprint/privacy/terms)
- `*` — NotFound
Note: The settings experience is handled via the header sheet (no dedicated route; legal pages stay routable under /legal/:page).
Guards & Loaders
- `EventGuard` verifies event token in storage; attempts refresh; otherwise redirects to `/`.
- `PrefetchEvent` loads event metadata/theme on `:slug` routes.
- `OfflineFallback` surfaces offline banner and queues mutations.
- `EventGuard` — verifies event token in storage; attempts refresh; otherwise redirects to `/`.
- `PrefetchEvent` — loads event metadata/theme on `:slug` routes.
- `OfflineFallback` — surfaces offline banner and queues mutations.
Suggested Directory Structure
```
@@ -46,7 +47,7 @@ apps/guest-pwa/
PhotoLightbox.tsx // modal route
AchievementsPage.tsx
SlideshowPage.tsx
SettingsPage.tsx
SettingsSheet.tsx
LegalPage.tsx
NotFoundPage.tsx
components/
@@ -116,7 +117,7 @@ export const router = createBrowserRouter([
{ path: 'slideshow', element: <SlideshowPage /> },
],
},
{ path: '/settings', element: <SettingsPage /> },
// Settings sheet is rendered inside Header; no standalone route.
{ path: '/legal/:page', element: <LegalPage /> },
{ path: '*', element: <NotFoundPage /> },
]);
@@ -124,11 +125,13 @@ export const router = createBrowserRouter([
Component Checklist
- Layout
- `Header`, `InfoBar` (X Gäste online Y Aufgaben gelöst), `BottomNav`, `Toast`.
- `Header`, `InfoBar` (X Gäste online • Y Aufgaben gelöst), `BottomNav`, `Toast`.
- Entry
- `QRPinForm` (QR deep link or PIN fallback), `ProfileForm` (name/avatar).
- Home/Feed
- `CTAButtons` (Random Task, Emotion Picker, Quick Photo), `GalleryMasonry`, `FiltersBar`, `PhotoCard`.
- `HeroCard` (Willkommensgruess + Eventtitel) und `StatTiles` (online Gaeste, geloeste Aufgaben, letztes Upload).
- `CTAButtons` (Aufgabe ziehen, Direkt-Upload, Galerie) + `UploadQueueLink` fuer Warteschlange.
- `EmotionPickerGrid` und `GalleryPreview` als inhaltlicher Einstieg.
- Tasks
- `EmotionPickerGrid`, `TaskCard` (shows duration, group size, actions).
- Capture/Upload (photos only)
@@ -136,7 +139,7 @@ Component Checklist
- Photo View
- `PhotoLightbox` (modal), like/share controls, emotion tags.
- Settings & Legal
- `SettingsPage` sections, `LegalPage` renderer.
- `SettingsSheet` (Header-Overlay mit Namenseditor, eingebetteten Rechtsdokumenten, Cache-Leeren), `LegalPage` Renderer.
State & Data
- TanStack Query for server data (events, photos); optimistic updates for likes.

View File

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

View File

@@ -7,7 +7,7 @@ This document outlines the authentication requirements and implementation detail
## Authentication Flow
### 1. Authorization Request
- **Endpoint**: `POST /oauth/authorize`
- **Endpoint**: `GET /api/v1/oauth/authorize`
- **Method**: GET (redirect from frontend)
- **Parameters**:
- `client_id`: Fixed client ID for tenant-admin-app (`tenant-admin-app`)
@@ -21,7 +21,7 @@ This document outlines the authentication requirements and implementation detail
**Response**: Redirect to frontend with authorization code and state parameters.
### 2. Token Exchange
- **Endpoint**: `POST /oauth/token`
- **Endpoint**: `POST /api/v1/oauth/token`
- **Method**: POST
- **Content-Type**: `application/x-www-form-urlencoded`
- **Parameters**:
@@ -44,7 +44,7 @@ This document outlines the authentication requirements and implementation detail
```
### 3. Token Refresh
- **Endpoint**: `POST /oauth/token`
- **Endpoint**: `POST /api/v1/oauth/token`
- **Method**: POST
- **Content-Type**: `application/x-www-form-urlencoded`
- **Parameters**:
@@ -102,6 +102,19 @@ This document outlines the authentication requirements and implementation detail
### oauth_clients Table
```sql
CREATE TABLE oauth_clients (
id VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255) UNIQUE NOT NULL,
client_secret VARCHAR(255),
tenant_id BIGINT UNSIGNED NULL,
redirect_uris JSON NULL,
scopes JSON NULL,
is_active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT oauth_clients_tenant_id_foreign FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE SET NULL
);
```sql
CREATE TABLE oauth_clients (
id VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255) UNIQUE NOT NULL,
@@ -133,6 +146,20 @@ CREATE TABLE oauth_codes (
### refresh_tokens Table
```sql
CREATE TABLE refresh_tokens (
id VARCHAR(255) PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL,
client_id VARCHAR(255),
token VARCHAR(255) UNIQUE NOT NULL,
access_token VARCHAR(255),
scope TEXT,
ip_address VARCHAR(45),
user_agent TEXT,
expires_at TIMESTAMP,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```sql
CREATE TABLE refresh_tokens (
id VARCHAR(255) PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL,
@@ -261,18 +288,22 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- Revoke old refresh token immediately
- Limit refresh tokens per tenant to 5 active
### 3. Rate Limiting
### 3. Key Management
- RSA key pairs for signing are generated on demand and stored in storage/app/private.key (private) and storage/app/public.key (public).
- Treat the private key as a secret; rotate it alongside deploys that invalidate tenant tokens.
### 4. Rate Limiting
- Authorization requests: 10/minute per IP
- Token exchanges: 5/minute per IP
- Token validation: 100/minute per tenant
### 4. Logging and Monitoring
### 5. Logging and Monitoring
- Log all authentication attempts (success/failure)
- Monitor token usage patterns
- Alert on unusual activity (multiple failed attempts, token anomalies)
- Track refresh token usage for security analysis
### 5. Database Cleanup
### 6. Database Cleanup
- Cron job to remove expired authorization codes (daily)
- Remove expired refresh tokens (weekly)
- Clean blacklisted tokens after expiry (daily)
@@ -315,4 +346,10 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- Alert on PKCE validation failures
- Log all security-related events
This implementation provides secure, scalable authentication for the Fotospiel tenant system, following OAuth2 best practices with PKCE for public clients.
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.
@@ -19,6 +19,7 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
- **Response**: Neuer Access/Refresh-Token
- **Token Validation**: `GET /api/v1/tenant/me`
- **Redirect URI**: Standardmaessig `${origin}/admin/auth/callback` (per Vite-Env anpassbar)
- **Headers**: `Authorization: Bearer {access_token}`
- **Response**: `{ id, email, tenant_id, role, name }`
@@ -51,18 +52,18 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
- **Validierung**: Prüft Credit-Balance (1 Credit pro Event)
### Event-Details
- **GET /api/v1/tenant/events/{id}**
- **GET /api/v1/tenant/events/{slug}**
- **Headers**: `Authorization: Bearer {token}`
- **Response**: Erweitertes Event mit `{ tasks[], members, stats { likes, views, uploads } }`
### Event updaten
- **PATCH /api/v1/tenant/events/{id}**
- **PATCH /api/v1/tenant/events/{slug}**
- **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`, `If-Match: {etag}`
- **Body**: Partial Event-Daten (title, date, location, description)
- **Response**: Updated Event
### Event archivieren
- **DELETE /api/v1/tenant/events/{id}**
- **DELETE /api/v1/tenant/events/{slug}**
- **Headers**: `Authorization: Bearer {token}`, `If-Match: {etag}`
- **Response**: 204 No Content (soft-delete)
@@ -156,12 +157,26 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
- **Response**: `{ balance: number }`
### Ledger-Verlauf
- **GET /api/v1/tenant/ledger**
- **GET /api/v1/tenant/credits/ledger**
- **Headers**: `Authorization: Bearer {token}`
- **Params**: `page`, `per_page` (Pagination)
- **Response**: `{ data: LedgerEntry[], current_page, last_page }`
- **LedgerEntry**: `{ id, type, amount, credits, date, description, transactionId? }`
### Credits kaufen (In-App)
- **POST /api/v1/tenant/credits/purchase**
- **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
- **Body**: `{ package_id: string, credits_added: number, platform?: 'capacitor'|'web', transaction_id?: string, subscription_active?: boolean }`
- **Response**: `{ message, balance, subscription_active }`
- **Hinweis**: Wird nach erfolgreichen In-App-Kuferfolgen aufgerufen, aktualisiert Balance & Ledger.
### Credits synchronisieren
- **POST /api/v1/tenant/credits/sync**
- **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
- **Body**: `{ balance: number, subscription_active: boolean, last_sync: ISODateString }`
- **Response**: `{ balance, subscription_active, server_time }`
- **Hinweis**: Client meldet lokalen Stand; Server gibt Quelle-der-Wahrheit zurcck.
### Kauf-Intent erstellen
- **POST /api/v1/tenant/purchases/intent**
- **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
@@ -242,8 +257,9 @@ curl -H "Authorization: Bearer {token}" \
## Deployment
### Environment-Variablen
- **REACT_APP_API_URL**: Backend-API-URL (Pflicht)
- **REACT_APP_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht)
- **VITE_API_URL**: Backend-API-URL (Pflicht)
- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht)
- **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat)
### Build & Deploy
1. **Development**: `npm run dev`
@@ -254,4 +270,5 @@ curl -H "Authorization: Bearer {token}" \
- App ist PWA-fähig (Manifest, Service Worker).
- Installierbar auf Desktop/Mobile via "Zum Startbildschirm hinzufügen".
Für weitere Details siehe die spezifischen Dokumentationsdateien.
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]
D --> G[Photo Lightbox]
D --> H[Filters]
D --> I[Settings]
D --> I[Settings Sheet]
F --> D
G --> D
```
Gallery (mobile)
Settings open in-place via a sheet launched from the header gear; the sheet focuses on legal text and cache management while the theme toggle remains in the header.
```
+--------------------------------------------------+
| Toolbar: [Scan?] [Filter] [Upload] [Menu] |

84
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "html",
"name": "fotospiel-app",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -7,6 +7,7 @@
"dependencies": {
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0",
"@playwright/mcp": "^0.0.37",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
@@ -28,6 +29,7 @@
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"globals": "^15.14.0",
"html5-qrcode": "^2.3.8",
"laravel-vite-plugin": "^2.0",
"lucide-react": "^0.475.0",
"playwright": "^1.55.0",
@@ -43,6 +45,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3",
"@playwright/test": "^1.55.0",
"@types/node": "^22.13.5",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1",
@@ -1773,6 +1776,78 @@
"dev": true,
"license": "MIT"
},
"node_modules/@playwright/mcp": {
"version": "0.0.37",
"resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.37.tgz",
"integrity": "sha512-BnI2Ijim1rhIGhoFKJRCa+MaWtNr7M2lnLeDldDsR0n+ZB2G7zjt+MAMqy5eRD/mMiWsTaQsXlzZmXeixqBdsA==",
"dependencies": {
"playwright": "1.56.0-alpha-2025-09-06",
"playwright-core": "1.56.0-alpha-2025-09-06"
},
"bin": {
"mcp-server-playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/mcp/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/mcp/node_modules/playwright": {
"version": "1.56.0-alpha-2025-09-06",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0-alpha-2025-09-06.tgz",
"integrity": "sha512-suVjiF5eeUtIqFq5E/5LGgkV0/bRSik87N+M7uLsjPQrKln9QHbZt3cy7Zybicj3ZqTBWWHvpN9b4cnpg6hS0g==",
"dependencies": {
"playwright-core": "1.56.0-alpha-2025-09-06"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@playwright/mcp/node_modules/playwright-core": {
"version": "1.56.0-alpha-2025-09-06",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0-alpha-2025-09-06.tgz",
"integrity": "sha512-B2s/cuqYuu+mT4hIHG8gIOXjCSKh0Np1gJNCp0CrDk/UTLB74gThwXiyPAJU0fADIQH6Dv1glv8ZvKTDVT8Fng==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -5991,6 +6066,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -8818,7 +8899,6 @@
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.3.1.tgz",
"integrity": "sha512-sgai5gahy/TiyTiqJEwIFpAuPhmkpt7sGVdRfcmNH53Yc3yI57+zFVmIaqbTST0jP/7tSqZuI0aSllXL2HIw5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/ni": "^25.0.0",
"@babel/core": "^7.28.0",

View File

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

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());
});
const ASSETS_CACHE = 'guest-assets-v1';
const IMAGES_CACHE = 'guest-images-v1';
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET' || !req.url.startsWith(self.location.origin)) return;
event.respondWith((async () => {
const cache = await caches.open('guest-runtime');
const cached = await cache.match(req);
if (cached) return cached;
try {
const res = await fetch(req);
// Cache static assets and images
const ct = res.headers.get('content-type') || '';
if (res.ok && (ct.includes('text/css') || ct.includes('javascript') || ct.startsWith('image/'))) {
cache.put(req, res.clone());
if (req.method !== 'GET') return;
const url = new URL(req.url);
// Only handle same-origin requests
if (url.origin !== self.location.origin) return;
// Never cache API calls; let them hit network directly
if (url.pathname.startsWith('/api/')) return;
// Cache-first for images
if (req.destination === 'image' || /\.(png|jpg|jpeg|webp|avif|gif|svg)(\?.*)?$/i.test(url.pathname)) {
event.respondWith((async () => {
const cache = await caches.open(IMAGES_CACHE);
const cached = await cache.match(req);
if (cached) return cached;
try {
const res = await fetch(req, { credentials: 'same-origin' });
if (res.ok) cache.put(req, res.clone());
return res;
} catch (e) {
return cached || Response.error();
}
return res;
} catch (e) {
return cached || new Response('Offline', { status: 503 });
}
})());
})());
return;
}
// Stale-while-revalidate for CSS/JS assets
if (req.destination === 'style' || req.destination === 'script') {
event.respondWith((async () => {
const cache = await caches.open(ASSETS_CACHE);
const cached = await cache.match(req);
const networkPromise = fetch(req, { credentials: 'same-origin' })
.then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
})
.catch(() => null);
return cached || (await networkPromise) || Response.error();
})());
return;
}
});
self.addEventListener('sync', (event) => {

0
redirect_uris Normal file
View File

View File

@@ -1,98 +1,103 @@
export async function login(email: string, password: string): Promise<{ token: string }> {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const res = await fetch('/api/v1/tenant/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Login failed');
return res.json();
import { authorizedFetch } from './auth/tokens';
type JsonValue = Record<string, any>;
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
if (!response.ok) {
const body = await safeJson(response);
console.error('[API]', message, response.status, body);
throw new Error(message);
}
return (await response.json()) as T;
}
async function safeJson(response: Response): Promise<JsonValue | null> {
try {
return (await response.clone().json()) as JsonValue;
} catch {
return null;
}
}
export async function getEvents(): Promise<any[]> {
const token = localStorage.getItem('ta_token') || '';
const res = await fetch('/api/v1/tenant/events', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to load events');
const json = await res.json();
return json.data ?? [];
const response = await authorizedFetch('/api/v1/tenant/events');
const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load events');
return data.data ?? [];
}
export async function createEvent(payload: { name: string; slug: string; date?: string; is_active?: boolean }): Promise<number> {
const token = localStorage.getItem('ta_token') || '';
const res = await fetch('/api/v1/tenant/events', {
const response = await authorizedFetch('/api/v1/tenant/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Failed to create event');
const json = await res.json();
return json.id;
const data = await jsonOrThrow<{ id: number }>(response, 'Failed to create event');
return data.id;
}
export async function updateEvent(id: number, payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>): Promise<void> {
const token = localStorage.getItem('ta_token') || '';
const res = await fetch(`/api/v1/tenant/events/${id}`, {
export async function updateEvent(
id: number,
payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>
): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Failed to update event');
if (!response.ok) {
await safeJson(response);
throw new Error('Failed to update event');
}
}
export async function getEventPhotos(id: number): Promise<any[]> {
const token = localStorage.getItem('ta_token') || '';
const res = await fetch(`/api/v1/tenant/events/${id}/photos`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error('Failed to load photos');
const json = await res.json();
return json.data ?? [];
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/photos`);
const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load photos');
return data.data ?? [];
}
export async function featurePhoto(id: number) {
const token = localStorage.getItem('ta_token') || '';
await fetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
export async function featurePhoto(id: number): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST' });
if (!response.ok) {
await safeJson(response);
throw new Error('Failed to feature photo');
}
}
export async function unfeaturePhoto(id: number) {
const token = localStorage.getItem('ta_token') || '';
await fetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
export async function unfeaturePhoto(id: number): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST' });
if (!response.ok) {
await safeJson(response);
throw new Error('Failed to unfeature photo');
}
}
export async function getEvent(id: number): Promise<any> {
const token = localStorage.getItem('ta_token') || '';
const res = await fetch(`/api/v1/tenant/events/${id}`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error('Failed to load event');
return res.json();
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`);
return jsonOrThrow<any>(response, 'Failed to load event');
}
export async function toggleEvent(id: number): Promise<boolean> {
const token = localStorage.getItem('ta_token') || '';
const res = await fetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error('Failed to toggle');
const json = await res.json();
return !!json.is_active;
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST' });
const data = await jsonOrThrow<{ is_active: boolean }>(response, 'Failed to toggle event');
return !!data.is_active;
}
export async function getEventStats(id: number): Promise<{ total: number; featured: number; likes: number }> {
const token = localStorage.getItem('ta_token') || '';
const res = await fetch(`/api/v1/tenant/events/${id}/stats`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error('Failed to load stats');
return res.json();
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/stats`);
return jsonOrThrow<{ total: number; featured: number; likes: number }>(response, 'Failed to load stats');
}
export async function createInviteLink(id: number): Promise<string> {
const token = localStorage.getItem('ta_token') || '';
const res = await fetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error('Failed to create invite');
const json = await res.json();
return json.link as string;
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST' });
const data = await jsonOrThrow<{ link: string }>(response, 'Failed to create invite');
return data.link;
}
export async function deletePhoto(id: number) {
const token = localStorage.getItem('ta_token') || '';
await fetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
export async function deletePhoto(id: number): Promise<void> {
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE' });
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 { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { AuthProvider } from './auth/context';
import { router } from './router';
import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance';
@@ -9,7 +10,8 @@ initializeTheme();
const rootEl = document.getElementById('root')!;
createRoot(rootEl).render(
<React.StrictMode>
<RouterProvider router={router} />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</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 { useNavigate, useSearchParams } from 'react-router-dom';
import { getEvent, getEventStats, toggleEvent, createInviteLink } from '../api';
import { Button } from '@/components/ui/button';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { createInviteLink, getEvent, getEventStats, toggleEvent } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventDetailPage() {
const [sp] = useSearchParams();
@@ -11,35 +12,67 @@ export default function EventDetailPage() {
const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null);
const [invite, setInvite] = React.useState<string | null>(null);
async function load() {
const e = await getEvent(id);
setEv(e);
setStats(await getEventStats(id));
}
React.useEffect(() => { load(); }, [id]);
const load = React.useCallback(async () => {
try {
const event = await getEvent(id);
setEv(event);
setStats(await getEventStats(id));
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}, [id]);
React.useEffect(() => {
load();
}, [load]);
async function onToggle() {
const isActive = await toggleEvent(id);
setEv((o: any) => ({ ...(o || {}), is_active: isActive }));
try {
const isActive = await toggleEvent(id);
setEv((previous: any) => ({ ...(previous || {}), is_active: isActive }));
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
async function onInvite() {
const link = await createInviteLink(id);
setInvite(link);
try { await navigator.clipboard.writeText(link); } catch {}
try {
const link = await createInviteLink(id);
setInvite(link);
try {
await navigator.clipboard.writeText(link);
} catch {
// clipboard may be unavailable
}
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
if (!ev) return <div className="p-4">Lade</div>;
const joinLink = `${location.origin}/e/${ev.slug}`;
if (!ev) {
return <div className="p-4">Lade ...</div>;
}
const joinLink = `${window.location.origin}/e/${ev.slug}`;
const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`;
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">
<h1 className="text-lg font-semibold">Event: {renderName(ev.name)}</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={onToggle}>{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}</Button>
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>Fotos moderieren</Button>
<Button variant="secondary" onClick={onToggle}>
{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>
Fotos moderieren
</Button>
</div>
</div>
<div className="rounded border p-3 text-sm">
@@ -47,31 +80,45 @@ export default function EventDetailPage() {
<div>Datum: {ev.date ?? '-'}</div>
<div>Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}</div>
</div>
<div className="grid grid-cols-3 gap-3 text-center text-sm">
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.total ?? 0}</div><div>Fotos</div></div>
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.featured ?? 0}</div><div>Featured</div></div>
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.likes ?? 0}</div><div>Likes gesamt</div></div>
<div className="grid grid-cols-1 gap-3 text-center text-sm sm:grid-cols-3">
<StatCard label="Fotos" value={stats?.total ?? 0} />
<StatCard label="Featured" value={stats?.featured ?? 0} />
<StatCard label="Likes gesamt" value={stats?.likes ?? 0} />
</div>
<div className="rounded border p-3">
<div className="mb-2 text-sm font-medium">Join-Link</div>
<div className="rounded border p-3 text-sm">
<div className="mb-2 font-medium">Join-Link</div>
<div className="mb-2 flex items-center gap-2">
<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 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" />
<div className="mt-3">
<Button variant="secondary" onClick={onInvite}>Einladungslink erzeugen</Button>
{invite && <div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>}
<Button variant="secondary" onClick={onInvite}>
Einladungslink erzeugen
</Button>
{invite && (
<div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</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 {
if (typeof name === 'string') return name;
if (name && (name.de || name.en)) return name.de || name.en;
return JSON.stringify(name);
}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { createEvent, updateEvent } from '../api';
import { isAuthError } from '../auth/tokens';
import { useNavigate, useSearchParams } from 'react-router-dom';
export default function EventFormPage() {
@@ -13,10 +14,12 @@ export default function EventFormPage() {
const [date, setDate] = React.useState('');
const [active, setActive] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const isEdit = !!id;
async function save() {
setSaving(true);
setError(null);
try {
if (isEdit) {
await updateEvent(Number(id), { name, slug, date, is_active: active });
@@ -24,21 +27,31 @@ export default function EventFormPage() {
await createEvent({ name, slug, date, is_active: active });
}
nav('/admin/events');
} finally { setSaving(false); }
} catch (e) {
if (!isAuthError(e)) {
setError('Speichern fehlgeschlagen');
}
} finally {
setSaving(false);
}
}
return (
<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>
{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="Slug" value={slug} onChange={(e) => setSlug(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">
<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>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
import { Button } from '@/components/ui/button';
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventPhotosPage() {
const [sp] = useSearchParams();
@@ -9,33 +10,83 @@ export default function EventPhotosPage() {
const [rows, setRows] = React.useState<any[]>([]);
const [loading, setLoading] = React.useState(true);
async function load() {
const load = React.useCallback(async () => {
setLoading(true);
try { setRows(await getEventPhotos(id)); } finally { setLoading(false); }
}
React.useEffect(() => { load(); }, [id]);
try {
setRows(await getEventPhotos(id));
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
} finally {
setLoading(false);
}
}, [id]);
async function onFeature(p: any) { await featurePhoto(p.id); load(); }
async function onUnfeature(p: any) { await unfeaturePhoto(p.id); load(); }
async function onDelete(p: any) { await deletePhoto(p.id); load(); }
React.useEffect(() => {
load();
}, [load]);
async function onFeature(photo: any) {
try {
await featurePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
async function onUnfeature(photo: any) {
try {
await unfeaturePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
async function onDelete(photo: any) {
try {
await deletePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
return (
<div className="mx-auto max-w-5xl p-4">
<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">
{rows.map((p) => (
<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">
<span> {p.likes_count}</span>
<span>?? {p.likes_count}</span>
<div className="flex gap-1">
{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>
@@ -44,4 +95,3 @@ export default function EventPhotosPage() {
</div>
);
}

View File

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

View File

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

View File

@@ -2,22 +2,34 @@ import React from 'react';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Button } from '@/components/ui/button';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/context';
export default function SettingsPage() {
const nav = useNavigate();
function logout() {
localStorage.removeItem('ta_token');
nav('/admin/login', { replace: true });
const { user, logout } = useAuth();
function handleLogout() {
logout({ redirect: '/admin/login' });
}
return (
<div className="mx-auto max-w-sm p-6">
<h1 className="mb-4 text-lg font-semibold">Einstellungen</h1>
<div className="mb-4">
<div className="mx-auto max-w-sm space-y-4 p-6">
<div>
<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>
<AppearanceToggleDropdown />
</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>
);
}

View File

@@ -1,20 +1,36 @@
import React from 'react';
import { createBrowserRouter, Outlet, Navigate } from 'react-router-dom';
import { createBrowserRouter, Outlet, Navigate, useLocation } from 'react-router-dom';
import LoginPage from './pages/LoginPage';
import EventsPage from './pages/EventsPage';
import SettingsPage from './pages/SettingsPage';
import EventFormPage from './pages/EventFormPage';
import EventPhotosPage from './pages/EventPhotosPage';
import EventDetailPage from './pages/EventDetailPage';
import AuthCallbackPage from './pages/AuthCallbackPage';
import { useAuth } from './auth/context';
function RequireAuth() {
const token = localStorage.getItem('ta_token');
if (!token) return <Navigate to="/admin/login" replace />;
const { status } = useAuth();
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 />;
}
export const router = createBrowserRouter([
{ path: '/admin/login', element: <LoginPage /> },
{ path: '/admin/auth/callback', element: <AuthCallbackPage /> },
{
path: '/admin',
element: <RequireAuth />,

View File

@@ -1,18 +1,25 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Link } from 'react-router-dom';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import React from 'react';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Settings, ChevronDown, User } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { User } from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
import { usePollStats } from '../polling/usePollStats';
import { useOptionalEventStats } from '../context/EventStatsContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { SettingsSheet } from './settings-sheet';
export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
const statsContext = useOptionalEventStats();
const identity = useOptionalGuestIdentity();
if (!slug) {
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
return (
<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">
<AppearanceToggleDropdown />
<SettingsSheet />
@@ -22,7 +29,8 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
}
const { event, loading: eventLoading, error: eventError } = useEventData();
const stats = usePollStats(slug);
const stats = statsContext && statsContext.slug === slug ? statsContext : undefined;
const guestName = identity && identity.slug === slug && identity.hydrated && identity.name ? identity.name : null;
if (eventLoading) {
return (
@@ -48,7 +56,6 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
// Get event icon or generate initials
const getEventAvatar = (event: any) => {
if (event.type?.icon) {
return (
@@ -57,8 +64,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
</div>
);
}
// Fallback to initials
const getInitials = (name: string) => {
const words = name.split(' ');
if (words.length >= 2) {
@@ -80,6 +86,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
{getEventAvatar(event)}
<div className="flex flex-col">
<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">
{stats && (
<>
@@ -87,9 +96,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
<User className="h-3 w-3" />
<span>{stats.onlineGuests} online</span>
</span>
<span></span>
<span className="text-muted-foreground">|</span>
<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>
</>
)}
@@ -104,88 +113,4 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
function SettingsSheet() {
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>
);
}
export {}

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 { Page } from './_util';
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import {
AchievementBadge,
AchievementsPayload,
FeedEntry,
LeaderboardEntry,
TimelinePoint,
TopPhotoHighlight,
TrendingEmotionHighlight,
fetchAchievements,
} from '../services/achievementApi';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react';
export default function AchievementsPage() {
function formatNumber(value: number): string {
return new Intl.NumberFormat('de-DE').format(value);
}
function formatRelativeTime(input: string): string {
const date = new Date(input);
if (Number.isNaN(date.getTime())) return '';
const diff = Date.now() - date.getTime();
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return 'gerade eben';
if (diff < hour) {
const minutes = Math.round(diff / minute);
return `vor ${minutes} Min`;
}
if (diff < day) {
const hours = Math.round(diff / hour);
return `vor ${hours} Std`;
}
const days = Math.round(diff / day);
return `vor ${days} Tagen`;
}
function badgeVariant(earned: boolean): string {
return earned ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/30' : 'bg-muted text-muted-foreground';
}
function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) {
return (
<Page title="Erfolge">
<p>Badges and progress placeholder.</p>
</Page>
<Card>
<CardHeader className="flex flex-row items-center gap-2 pb-4">
<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, '/');
}
// Extended debug logging
console.log(`Photo ${p.id} URL processing:`, {
id: p.id,
original: imgSrc,
thumbnail_path: p.thumbnail_path,
file_path: p.file_path,
cleanPath,
finalUrl: imageUrl,
isHttp: imageUrl?.startsWith('http'),
startsWithStorage: imageUrl?.startsWith('/storage/')
});
// Production: avoid heavy console logging for each image
return (
<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}` : ''}`}
className="aspect-square w-full object-cover bg-gray-200"
onError={(e) => {
console.error(`❌ Failed to load image ${p.id}:`, imageUrl);
console.error('Error details:', e);
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}}
onLoad={() => console.log(`✅ Successfully loaded image ${p.id}:`, imageUrl)}
loading="lazy"
/>
</div>

View File

@@ -1,37 +1,186 @@
import React from 'react';
import { Page } from './_util';
import { useParams, Link } from 'react-router-dom';
import { usePollStats } from '../polling/usePollStats';
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import Header from '../components/Header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview';
import BottomNav from '../components/BottomNav';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventStats } from '../context/EventStatsContext';
import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
export default function HomePage() {
const { slug } = useParams();
const stats = usePollStats(slug!);
const { slug } = useParams<{ slug: string }>();
const { name, hydrated } = useGuestIdentity();
const stats = useEventStats();
const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(slug);
if (!slug) return null;
const displayName = hydrated && name ? name : 'Gast';
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
const primaryActions: Array<{ to: string; label: string; description: string; icon: React.ReactNode }> = [
{
to: 'tasks',
label: 'Aufgabe ziehen',
description: 'Hol dir deine naechste Challenge',
icon: <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 (
<Page title={`Event: ${slug}`}>
<Header slug={slug!} title={`Event: ${slug}`} />
<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>
<div className="space-y-6 pb-24">
<HeroCard name={displayName} eventName={event?.name ?? 'Dein Event'} tasksCompleted={completedCount} />
{/* How do you feel? Section */}
<EmotionPicker />
<Card>
<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!} />
</div>
{/* Bottom Navigation */}
<BottomNav />
</Page>
<section className="space-y-3">
<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>
<Card>
<CardHeader>
<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 { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Html5Qrcode } from 'html5-qrcode';
import { readGuestName } from '../context/GuestIdentityContext';
export default function LandingPage() {
const nav = useNavigate();
const [slug, setSlug] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [slug, setSlug] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
async function join() {
const s = slug.trim();
async function join(eventSlug?: string) {
const s = (eventSlug ?? slug).trim();
if (!s) return;
setLoading(true);
setError(null);
@@ -22,30 +27,131 @@ export default function LandingPage() {
setError('Event nicht gefunden oder geschlossen.');
return;
}
nav(`/e/${encodeURIComponent(s)}`);
const storedName = readGuestName(s);
if (!storedName) {
nav(`/setup/${encodeURIComponent(s)}`);
} else {
nav(`/e/${encodeURIComponent(s)}`);
}
} catch (e) {
setError('Netzwerkfehler. Bitte später erneut versuchen.');
console.error('Join request failed', e);
setError('Netzwerkfehler. Bitte spaeter erneut versuchen.');
} finally {
setLoading(false);
}
}
const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const;
async function startScanner() {
if (scanner) {
try {
await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (err) {
console.error('Scanner start failed', err);
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
}
return;
}
try {
const newScanner = new Html5Qrcode('qr-reader');
setScanner(newScanner);
await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (err) {
console.error('Scanner initialisation failed', err);
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
}
}
function stopScanner() {
if (!scanner) {
setIsScanning(false);
return;
}
scanner
.stop()
.then(() => {
setIsScanning(false);
})
.catch((err) => console.error('Scanner stop failed', err));
}
async function onScanSuccess(decodedText: string) {
const value = decodedText.trim();
if (!value) return;
await join(value);
stopScanner();
}
useEffect(() => () => {
if (scanner) {
scanner.stop().catch(() => undefined);
}
}, [scanner]);
return (
<Page title="Willkommen bei Fotochallenge 🎉">
<Page title="Willkommen bei der Fotobox!">
{error && (
<Alert className="mb-3" variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="QR/PIN oder Event-Slug eingeben"
/>
<div className="h-3" />
<Button disabled={loading || !slug.trim()} onClick={join}>
{loading ? 'Prüfe…' : 'Event beitreten'}
</Button>
<div className="space-y-6 pb-20">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-gray-900">Willkommen bei der Fotobox!</h1>
<p className="text-lg text-gray-600">Dein Schluessel zu unvergesslichen Momenten.</p>
</div>
<Card className="mx-auto w-full max-w-md">
<CardHeader className="text-center">
<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>
);
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import React from "react";
import { Page } from './_util';
import { useParams } from 'react-router-dom';
import { LegalMarkdown } from '../components/legal-markdown';
export default function LegalPage() {
const { page } = useParams();
@@ -9,42 +10,44 @@ export default function LegalPage() {
const [body, setBody] = React.useState('');
React.useEffect(() => {
async function load() {
setLoading(true);
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page || '')}?lang=de`, { headers: { 'Cache-Control': 'no-store' }});
if (res.ok) {
const j = await res.json();
setTitle(j.title || '');
setBody(j.body_markdown || '');
}
setLoading(false);
if (!page) {
return;
}
if (page) load();
const controller = new AbortController();
async function loadLegal() {
try {
setLoading(true);
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, {
headers: { 'Cache-Control': 'no-store' },
signal: controller.signal,
});
if (!res.ok) {
throw new Error('failed');
}
const data = await res.json();
setTitle(data.title || '');
setBody(data.body_markdown || '');
} catch (error) {
if (!controller.signal.aborted) {
console.error('Failed to load legal page', error);
setTitle('');
setBody('');
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadLegal();
return () => controller.abort();
}, [page]);
return (
<Page title={title || `Rechtliches: ${page}` }>
{loading ? <p>Lädt</p> : <Markdown md={body} />}
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
</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 { Page } from './_util';
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useEventData } from '../hooks/useEventData';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import Header from '../components/Header';
export default function ProfileSetupPage() {
const { slug } = useParams<{ slug: string }>();
const nav = useNavigate();
const { event, loading, error } = useEventData();
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
const [name, setName] = useState(storedName);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!slug) {
nav('/');
return;
}
}, [slug, nav]);
useEffect(() => {
if (hydrated) {
setName(storedName);
}
}, [hydrated, storedName]);
function handleChange(value: string) {
setName(value);
}
function submitName() {
if (!slug) return;
const trimmedName = name.trim();
if (!trimmedName) return;
setSubmitting(true);
try {
persistName(trimmedName);
nav(`/e/${slug}`);
} catch (e) {
console.error('Fehler beim Speichern des Namens:', e);
setSubmitting(false);
}
}
if (loading) {
return (
<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 (
<Page title="Profil erstellen">
<input placeholder="Dein Name" style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 8 }} />
<div style={{ height: 12 }} />
<button style={{ padding: '10px 16px', borderRadius: 8, background: '#111827', color: 'white' }}>Starten</button>
</Page>
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
<Header slug={slug!} />
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
<Card className="w-full max-w-md">
<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 { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Page } from './_util';
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { useAppearance } from '../../hooks/use-appearance';
import { Clock, RefreshCw, Smile } from 'lucide-react';
import BottomNav from '../components/BottomNav';
import { useEventData } from '../hooks/useEventData';
import { EventData } from '../services/eventApi';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Sparkles, RefreshCw, Smile, Timer as TimerIcon, CheckCircle2, AlertTriangle } from 'lucide-react';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
interface Task {
id: number;
title: string;
description: string;
instructions: string;
duration: number; // in minutes
duration: number; // minutes
emotion?: {
slug: string;
name: string;
@@ -21,244 +19,508 @@ interface Task {
is_completed: boolean;
}
type EmotionOption = {
slug: string;
name: string;
};
const TASK_PROGRESS_TARGET = 5;
const TIMER_VIBRATION = [0, 60, 120, 60];
export default function TaskPickerPage() {
const { slug } = useParams<{ slug: string }>();
const [searchParams] = useSearchParams();
// emotionSlug = searchParams.get('emotion'); // Temporär deaktiviert, da API-Filter nicht verfügbar
const navigate = useNavigate();
const { appearance } = useAppearance();
const isDark = appearance === 'dark';
const [tasks, setTasks] = useState<Task[]>([]);
const [currentTask, setCurrentTask] = useState<Task | null>(null);
const [timeLeft, setTimeLeft] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
// Timer state
useEffect(() => {
if (!currentTask) return;
const durationMs = currentTask.duration * 60 * 1000;
setTimeLeft(durationMs / 1000);
const interval = setInterval(() => {
setTimeLeft(prev => {
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
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 recentTaskIdsRef = React.useRef<number[]>([]);
const initialEmotionRef = React.useRef(false);
const fetchTasks = React.useCallback(async () => {
if (!slug) return;
setIsFetching(true);
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
if (Array.isArray(payload)) {
setTasks(payload);
} else {
setTasks([]);
}
} catch (err) {
console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
setTasks([]);
} finally {
setIsFetching(false);
setLoading(false);
}
}, [slug]);
React.useEffect(() => {
fetchTasks();
}, [fetchTasks]);
React.useEffect(() => {
if (initialEmotionRef.current) return;
const queryEmotion = searchParams.get('emotion');
if (queryEmotion) {
setSelectedEmotion(queryEmotion);
}
initialEmotionRef.current = true;
}, [searchParams]);
const emotionOptions = React.useMemo<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) {
clearInterval(interval);
window.clearInterval(tick);
triggerTimeUp();
return 0;
}
return prev - 1;
});
}, 1000);
return () => window.clearInterval(tick);
}, [timerRunning, timeLeft]);
return () => clearInterval(interval);
}, [currentTask]);
// Load tasks
useEffect(() => {
if (!slug) return;
async function fetchTasks() {
try {
setLoading(true);
setError(null);
const url = `/api/v1/events/${slug}/tasks`;
console.log('Fetching tasks from:', url); // Debug
const response = await fetch(url);
if (!response.ok) throw new Error('Tasks konnten nicht geladen werden');
const data = await response.json();
setTasks(Array.isArray(data) ? data : []);
console.log('Loaded tasks:', data); // Debug
// Select random task
if (data.length > 0) {
const randomIndex = Math.floor(Math.random() * data.length);
setCurrentTask(data[randomIndex]);
console.log('Selected random task:', data[randomIndex]); // Debug
}
} catch (err) {
console.error('Fetch tasks error:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
} finally {
setLoading(false);
}
function triggerTimeUp() {
const supportsVibration = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
setTimerRunning(false);
setTimeUp(true);
if (supportsVibration) {
try {
navigator.vibrate(TIMER_VIBRATION);
} catch (error) {
console.warn('Vibration not permitted', error);
}
}
window.setTimeout(() => setTimeUp(false), 4000);
return;
}
fetchTasks();
}, [slug]);
const formatTime = (seconds: number) => {
const formatTime = React.useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
const secs = Math.max(0, seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}, []);
const progressRatio = currentTask ? Math.min(1, completedCount / TASK_PROGRESS_TARGET) : 0;
const handleSelectEmotion = (slugValue: string) => {
setSelectedEmotion(slugValue);
const next = new URLSearchParams(searchParams.toString());
if (slugValue === 'all') {
next.delete('emotion');
} else {
next.set('emotion', slugValue);
}
setSearchParams(next, { replace: true });
};
const handleNewTask = () => {
if (tasks.length === 0) return;
const randomIndex = Math.floor(Math.random() * tasks.length);
setCurrentTask(tasks[randomIndex]);
setTimeLeft(tasks[randomIndex].duration * 60);
selectRandomTask(filteredTasks);
};
const handleStartTask = () => {
const handleStartUpload = () => {
if (!currentTask || !slug) return;
navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
};
const handleMarkCompleted = () => {
if (!currentTask) return;
// Navigate to upload with task context
navigate(`/e/${slug}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
markCompleted(currentTask.id);
selectRandomTask(filteredTasks);
};
const handleChangeMood = () => {
navigate(`/e/${slug}`);
const handleRetryFetch = () => {
fetchTasks();
};
if (loading) {
return (
<Page title="Aufgabe laden...">
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${
isDark ? 'text-white' : 'text-gray-900'
}`}>
<RefreshCw className="h-8 w-8 animate-spin mb-4" />
<p className="text-sm">Lade Aufgabe...</p>
</div>
<BottomNav />
</Page>
);
}
const handleTimerToggle = () => {
if (!currentTask) return;
if (timerRunning) {
setTimerRunning(false);
setTimeLeft(currentTask.duration * 60);
setTimeUp(false);
} else {
if (timeLeft <= 0) {
setTimeLeft(currentTask.duration * 60);
}
setTimerRunning(true);
setTimeUp(false);
}
};
if (error || !currentTask) {
return (
<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>
);
}
const emptyState = !loading && (!filteredTasks.length || !currentTask);
return (
<Page title={currentTask.title}>
<div className={`min-h-screen flex flex-col ${
isDark ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'
}`}>
{/* Task Header with Selfie Overlay */}
<div className="p-4 space-y-4">
<div className="relative">
{/* Selfie Placeholder */}
<div className={`w-full aspect-square rounded-2xl bg-gradient-to-br ${
isDark
? 'from-gray-800 to-gray-700 shadow-2xl'
: 'from-pink-50 to-pink-100 shadow-lg'
} flex items-center justify-center`}>
<div className="text-center space-y-2">
<div className={`w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-2 ${
isDark ? 'text-white' : 'text-gray-600'
}`}>
📸
</div>
<p className={`text-sm font-medium ${
isDark ? 'text-gray-300' : 'text-gray-600'
}`}>
Selfie-Vorschau
</p>
</div>
</div>
{/* Timer */}
<div className="absolute top-2 right-2">
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
timeLeft > 60
? 'bg-green-500/20 text-green-400 border-green-500/30'
: timeLeft > 30
? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
: 'bg-red-500/20 text-red-400 border-red-500/30'
} border`}>
<Clock className="h-3 w-3" />
<span>{formatTime(timeLeft)}</span>
</div>
</div>
<div className="space-y-6">
<header className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold text-foreground">Aufgabe auswaehlen</h1>
<Badge variant="secondary" className="whitespace-nowrap">
Schon {completedCount} Aufgaben erledigt
</Badge>
</div>
<div className="rounded-xl border bg-muted/40 p-4">
<div className="flex items-center justify-between text-sm font-medium text-muted-foreground">
<span>Auf dem Weg zum naechsten Erfolg</span>
<span>
{completedCount >= TASK_PROGRESS_TARGET
? 'Stark!'
: `${Math.max(0, TASK_PROGRESS_TARGET - completedCount)} bis zum Badge`}
</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>
{/* 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>
{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>
<div className="absolute right-3 top-3 flex flex-col items-end gap-2">
<BadgeTimer
label="Countdown"
value={formatTime(timeLeft)}
tone={timerTone(timeLeft, currentTask.duration)}
/>
{timeUp && (
<Badge variant="destructive" className="flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" />
Zeit abgelaufen!
</Badge>
)}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="px-4 pb-4 space-y-3">
<Button
onClick={handleStartTask}
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"
>
<span className="flex items-center justify-center gap-2">
📸 Los geht's
</span>
</Button>
<div className="space-y-4 p-5">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="flex items-center gap-2">
<TimerIcon className="h-4 w-4" />
{currentTask.duration} Min
</Badge>
{currentTask.emotion?.name && (
<Badge variant="outline" className="flex items-center gap-2">
<Smile className="h-4 w-4" />
{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">
<Button
variant="outline"
onClick={handleNewTask}
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"
>
<RefreshCw className="h-4 w-4 mr-2" />
Neue Aufgabe
<p className="text-sm leading-relaxed text-muted-foreground">{currentTask.description}</p>
{currentTask.instructions && (
<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">
{currentTask.instructions}
</div>
)}
<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
variant="ghost"
onClick={handleChangeMood}
className="flex-1 h-12 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
<Button
variant={timerRunning ? 'destructive' : 'outline'}
onClick={handleTimerToggle}
className="h-14 text-base"
>
<Smile className="h-4 w-4 mr-2" />
Andere Stimmung
<span className="flex items-center justify-center gap-2">
<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>
</div>
</div>
)}
{/* Bottom Navigation */}
<BottomNav />
</div>
</Page>
{!loading && !tasks.length && !error && (
<Alert>
<AlertDescription>Fuer dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
</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 latestAt = useRef<string | null>(null);
const timer = useRef<number | null>(null);
const [visible, setVisible] = useState(
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
);
async function fetchDelta() {
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(() => {
setLoading(true);
latestAt.current = null;
setPhotos([]);
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 () => {
if (timer.current) window.clearInterval(timer.current);
};
}, [slug]);
}, [slug, visible]);
function acknowledgeNew() { setNewCount(0); }
return { loading, photos, newCount, acknowledgeNew };

View File

@@ -1,39 +1,67 @@
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) {
const [data, setData] = useState<Stats | null>(null);
type StatsResponse = {
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 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 {
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' },
});
if (res.status === 304) return;
const json = await res.json();
setData({ onlineGuests: json.online_guests ?? 0, tasksSolved: json.tasks_solved ?? 0, latestPhotoAt: json.latest_photo_at });
const json: StatsResponse = await res.json();
setData({
onlineGuests: json.online_guests ?? 0,
tasksSolved: json.tasks_solved ?? 0,
latestPhotoAt: json.latest_photo_at ?? null,
});
} finally {
setLoading(false);
}
}
useEffect(() => {
setLoading(true);
fetchOnce();
function schedule() {
if (!visible) return;
timer.current = window.setInterval(fetchOnce, 10_000);
const onVis = () => setVisible(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, []);
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 () => {
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 { createUpload } from './xhr';
import { notify } from './notify';
type SyncManager = { register(tag: string): Promise<void>; };
export type QueueItem = {
id?: number;
@@ -26,7 +27,10 @@ export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries'
});
// Register background sync if available
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 Header from './components/Header';
import BottomNav from './components/BottomNav';
import { EventStatsProvider } from './context/EventStatsContext';
import { GuestIdentityProvider } from './context/GuestIdentityContext';
import LandingPage from './pages/LandingPage';
import ProfileSetupPage from './pages/ProfileSetupPage';
import HomePage from './pages/HomePage';
@@ -19,20 +21,43 @@ import NotFoundPage from './pages/NotFoundPage';
function HomeLayout() {
const { slug } = useParams();
return (
<div className="pb-16">
{slug ? <Header slug={slug} /> : <Header title="Event" />}
<div className="px-4 py-3">
<Outlet />
if (!slug) {
return (
<div className="pb-16">
<Header title="Event" />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</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([
{ 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',
element: <HomeLayout />,
@@ -53,6 +78,21 @@ export const router = createBrowserRouter([
{ 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 }) {
return (
<div className="pb-16">
@@ -64,3 +104,4 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
</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="csrf-token" content="{{ csrf_token() }}">
<title>{{ __('admin.shell.tenant_admin_title') }}</title>
@viteReactRefresh
@vite('resources/js/admin/main.tsx')
</head>
<body>

View File

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

View File

@@ -1,4 +1,4 @@
<?php
<?php
use Illuminate\Http\Request;
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::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show');
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}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
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');
});
// Protected tenant API routes (require auth:sanctum + tenant middleware)
Route::middleware(['auth:sanctum', \App\Http\Middleware\TenantTokenGuard::class, \App\Http\Middleware\TenantIsolation::class])->prefix('tenant')->group(function () {
// Protected tenant API routes (JWT tenants via OAuth guard)
Route::middleware(['tenant.token', 'tenant.isolation'])->prefix('tenant')->group(function () {
Route::get('me', [OAuthController::class, 'me'])->name('tenant.me');
// 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('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger');
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);
});
// 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
Route::view('/admin/{any?}', 'admin')->where('any', '.*');
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,
hmr: {
host: parsedOrigin.hostname,
protocol: parsedOrigin.protocol.replace(':', ''),
port: hmrPort,
protocol: parsedOrigin.protocol.replace(':','') as 'http' | 'https',
clientPort: hmrPort,
},
fs: {
strict: true,
// Erlaube nur das App-Package (ggf. Pfade anpassen)
allow: [__dirname],
},
cors: {
origin: appUrl,
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: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.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(),
tailwindcss(),
@@ -42,4 +76,20 @@ export default defineConfig({
esbuild: {
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
},
},
});