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)
*/
@@ -468,3 +479,9 @@ class PhotoController extends Controller
], 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,7 +27,11 @@ 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);

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']);
$cacheKey = "blacklisted_token:{$jti}";
$jti = $decoded['jti'] ?? null;
if (! $jti) {
return false;
}
// Check cache first (faster)
$cacheKey = "blacklisted_token:{$jti}";
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

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

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)
@@ -316,3 +347,9 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- 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.

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`
@@ -255,3 +271,4 @@ curl -H "Authorization: Bearer {token}" \
- Installierbar auf Desktop/Mobile via "Zum Startbildschirm hinzufügen".
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 (
@@ -58,7 +65,6 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
// Fallback to initials
const getInitials = (name: string) => {
const words = name.split(' ');
if (words.length >= 2) {
@@ -80,6 +86,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
{getEventAvatar(event)}
<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>
<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>
{/* Bottom Navigation */}
<BottomNav />
</Page>
<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 [searchParams, setSearchParams] = useSearchParams();
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 { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
// Timer state
useEffect(() => {
if (!currentTask) return;
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 durationMs = currentTask.duration * 60 * 1000;
setTimeLeft(durationMs / 1000);
const recentTaskIdsRef = React.useRef<number[]>([]);
const initialEmotionRef = React.useRef(false);
const interval = setInterval(() => {
setTimeLeft(prev => {
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 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>
{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>
<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>
{/* Task Description Overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-transparent to-transparent p-4 rounded-b-2xl">
<div className="space-y-2">
<h1 className={`text-xl font-bold ${
isDark ? 'text-gray-100' : 'text-white'
}`}>
{currentTask.title}
</h1>
<p className={`text-sm leading-relaxed ${
isDark ? 'text-gray-200' : 'text-gray-100'
}`}>
{currentTask.description}
</p>
{currentTask.instructions && (
<div className={`p-2 rounded-lg ${
isDark
? 'bg-gray-700/80 text-gray-100'
: 'bg-gray-800/80 text-white border border-gray-600/50'
}`}>
<p className="text-xs italic">💡 {currentTask.instructions}</p>
</div>
<div 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"
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
},
},
});