die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.

This commit is contained in:
Codex Agent
2025-11-04 16:14:17 +01:00
parent 92e64c361a
commit fe380689fb
63 changed files with 4239 additions and 1142 deletions

View File

@@ -8,8 +8,8 @@ use App\Models\PurchaseHistory;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ExportBulkAction;
use Filament\Actions\ViewAction;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
@@ -148,7 +148,7 @@ class PurchaseHistoryResource extends Resource
->searchable(),
])
->actions([
Tables\Actions\ViewAction::make(),
ViewAction::make(),
])
->bulkActions([
BulkActionGroup::make([

View File

@@ -13,7 +13,7 @@ class CreditAlertsWidget extends StatsOverviewWidget
protected int|string|array $columnSpan = 'full';
protected function getCards(): array
protected function getStats(): array
{
$lowBalanceCount = Tenant::query()
->where('is_active', true)
@@ -58,4 +58,3 @@ class CreditAlertsWidget extends StatsOverviewWidget
];
}
}

View File

@@ -5,13 +5,13 @@ namespace App\Filament\Widgets;
use App\Models\MediaStorageTarget;
use App\Services\Storage\StorageHealthService;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Card;
use Filament\Widgets\StatsOverviewWidget\Stat;
class StorageCapacityWidget extends StatsOverviewWidget
{
protected static ?int $sort = 1;
protected function getCards(): array
protected function getStats(): array
{
$health = app(StorageHealthService::class);
@@ -20,7 +20,7 @@ class StorageCapacityWidget extends StatsOverviewWidget
$stats = $health->getCapacity($target);
if ($stats['status'] !== 'ok') {
return Card::make($target->name, 'Kapazität unbekannt')
return Stat::make($target->name, 'Kapazität unbekannt')
->description(match ($stats['status']) {
'unavailable' => 'Monitoring nicht verfügbar',
'unknown' => 'Monitor-Pfad nicht gesetzt',
@@ -46,7 +46,7 @@ class StorageCapacityWidget extends StatsOverviewWidget
$color = 'warning';
}
return Card::make($target->name, "$used / $total")
return Stat::make($target->name, "$used / $total")
->description("Frei: $free · Auslastung: $percent")
->color($color)
->extraAttributes([

View File

@@ -3,13 +3,11 @@
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\Photo;
use App\Models\Tenant;
use App\Services\Tenant\DashboardSummaryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
class DashboardController extends Controller
{
@@ -32,52 +30,8 @@ class DashboardController extends Controller
], 401);
}
$eventsQuery = Event::query()
->where('tenant_id', $tenant->getKey());
$summary = app(DashboardSummaryService::class)->build($tenant);
$activeEvents = (clone $eventsQuery)
->where(fn ($query) => $query
->where('is_active', true)
->orWhere('status', 'published'))
->count();
$upcomingEvents = (clone $eventsQuery)
->whereDate('date', '>=', Carbon::now()->startOfDay())
->count();
$eventsWithTasks = (clone $eventsQuery)
->whereHas('tasks')
->count();
$totalEvents = (clone $eventsQuery)->count();
$taskProgress = $totalEvents > 0
? (int) round(($eventsWithTasks / $totalEvents) * 100)
: 0;
$newPhotos = Photo::query()
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
->where('created_at', '>=', Carbon::now()->subDays(7))
->count();
$activePackage = $tenant->tenantPackages()
->with('package')
->where('active', true)
->orderByDesc('expires_at')
->orderByDesc('purchased_at')
->first();
return response()->json([
'active_events' => $activeEvents,
'new_photos' => $newPhotos,
'task_progress' => $taskProgress,
'credit_balance' => $tenant->event_credits_balance ?? null,
'upcoming_events' => $upcomingEvents,
'active_package' => $activePackage ? [
'name' => $activePackage->package?->getNameForLocale(app()->getLocale()) ?? $activePackage->package?->name ?? '',
'expires_at' => optional($activePackage->expires_at)->toIso8601String(),
'remaining_events' => $activePackage->remaining_events ?? null,
] : null,
]);
return response()->json($summary);
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\ProfileUpdateRequest;
use App\Models\User;
use App\Support\ApiError;
use App\Support\TenantAuth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class ProfileController extends Controller
{
public function show(Request $request): JsonResponse
{
try {
$user = TenantAuth::resolveAdminUser($request);
} catch (\Throwable $exception) {
Log::warning('[TenantProfile] Unable to resolve user for profile show', [
'tenant_id' => $request->attributes->get('tenant_id'),
'error' => $exception->getMessage(),
]);
return ApiError::response(
'profile_user_missing',
'Profil nicht verfügbar',
'Für diesen Tenant konnte kein Account gefunden werden.',
Response::HTTP_NOT_FOUND
);
}
return response()->json([
'data' => $this->transformUser($user),
]);
}
public function update(ProfileUpdateRequest $request): JsonResponse
{
try {
$user = TenantAuth::resolveAdminUser($request);
} catch (\Throwable $exception) {
Log::warning('[TenantProfile] Unable to resolve user for profile update', [
'tenant_id' => $request->attributes->get('tenant_id'),
'error' => $exception->getMessage(),
]);
return ApiError::response(
'profile_user_missing',
'Profil nicht verfügbar',
'Für diesen Tenant konnte kein Account gefunden werden.',
Response::HTTP_NOT_FOUND
);
}
$data = $request->validated();
$updates = [];
$emailChanged = false;
if (isset($data['name']) && $data['name'] !== $user->name) {
$updates['name'] = $data['name'];
}
if (array_key_exists('preferred_locale', $data) && $data['preferred_locale'] !== $user->preferred_locale) {
$updates['preferred_locale'] = $data['preferred_locale'] ? Str::lower($data['preferred_locale']) : null;
}
if (isset($data['email']) && Str::lower($data['email']) !== Str::lower((string) $user->email)) {
$updates['email'] = $data['email'];
$updates['email_verified_at'] = null;
$emailChanged = true;
}
if ($request->filled('password')) {
$currentPassword = (string) $request->input('current_password');
if (! $request->filled('current_password') || ! Hash::check($currentPassword, $user->password)) {
return ApiError::response(
'profile.invalid_current_password',
'Aktuelles Passwort ungültig',
'Das aktuelle Passwort stimmt nicht.',
Response::HTTP_UNPROCESSABLE_ENTITY,
[
'errors' => [
'current_password' => ['Das aktuelle Passwort stimmt nicht.'],
],
]
);
}
$updates['password'] = $request->input('password');
}
if ($updates !== []) {
$user->forceFill($updates);
$user->save();
if ($emailChanged) {
try {
$user->sendEmailVerificationNotification();
} catch (\Throwable $exception) {
Log::error('[TenantProfile] Failed to send verification email after profile update', [
'user_id' => $user->getKey(),
'tenant_id' => $request->attributes->get('tenant_id'),
'error' => $exception->getMessage(),
]);
}
}
}
return response()->json([
'message' => 'Profil erfolgreich aktualisiert.',
'data' => $this->transformUser($user->fresh()),
]);
}
/**
* @return array<string, mixed>
*/
private function transformUser(User $user): array
{
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'preferred_locale' => $user->preferred_locale,
'email_verified' => $user->email_verified_at !== null,
'email_verified_at' => $user->email_verified_at?->toIso8601String(),
];
}
}

View File

@@ -9,8 +9,10 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class AuthenticatedSessionController extends Controller
{
@@ -28,12 +30,13 @@ class AuthenticatedSessionController extends Controller
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
public function store(LoginRequest $request): SymfonyResponse
{
try {
$request->authenticate();
} catch (\Illuminate\Validation\ValidationException $e) {
$request->session()->flash('error', __('auth.login_failed'));
return redirect()->route('login')->withErrors($e->errors());
}
@@ -47,7 +50,12 @@ class AuthenticatedSessionController extends Controller
return Inertia::location(route('verification.notice'));
}
return Inertia::location(route('dashboard', absolute: false));
$returnTo = $this->resolveReturnTo($request);
if ($returnTo !== null) {
return Inertia::location($returnTo);
}
return Inertia::location($this->defaultAdminPath());
}
/**
@@ -62,4 +70,71 @@ class AuthenticatedSessionController extends Controller
return redirect('/');
}
private function resolveReturnTo(Request $request): ?string
{
$encoded = $request->string('return_to')->trim();
if ($encoded === '') {
return null;
}
return $this->decodeReturnTo($encoded, $request);
}
private function decodeReturnTo(string $value, Request $request): ?string
{
$candidate = $this->decodeBase64Url($value) ?? $value;
$candidate = trim($candidate);
if ($candidate === '') {
return null;
}
if (str_starts_with($candidate, '/')) {
return $candidate;
}
$targetHost = parse_url($candidate, PHP_URL_HOST);
$scheme = parse_url($candidate, PHP_URL_SCHEME);
if (! $scheme || ! $targetHost) {
return null;
}
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
return null;
}
return $candidate;
}
private function defaultAdminPath(): string
{
$base = rtrim(route('tenant.admin.app', absolute: false), '/');
if ($base === '') {
$base = '/event-admin';
}
return $base.'/events';
}
private function decodeBase64Url(string $value): ?string
{
if ($value === '') {
return null;
}
$padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '=');
$normalized = strtr($padded, '-_', '+/');
$decoded = base64_decode($normalized, true);
if ($decoded === false) {
return null;
}
return $decoded;
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers;
use App\Models\Event;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Services\Tenant\DashboardSummaryService;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(Request $request, DashboardSummaryService $summaryService): Response
{
$user = $request->user();
$tenant = $user?->tenant;
$summary = $tenant instanceof Tenant
? $summaryService->build($tenant)
: null;
$events = $tenant instanceof Tenant
? $this->collectUpcomingEvents($tenant)
: collect();
$purchases = $tenant instanceof Tenant
? $this->collectRecentPurchases($tenant)
: collect();
$activePackage = $summary['active_package'] ?? null;
return Inertia::render('dashboard', [
'metrics' => $summary,
'upcomingEvents' => $events->values()->all(),
'recentPurchases' => $purchases->values()->all(),
'latestPurchase' => $purchases->first(),
'tenant' => $tenant ? [
'id' => $tenant->id,
'name' => $tenant->name,
'eventCreditsBalance' => $tenant->event_credits_balance,
'subscriptionStatus' => $tenant->subscription_status,
'subscriptionExpiresAt' => optional($tenant->subscription_expires_at)->toIso8601String(),
'activePackage' => $activePackage ? [
'name' => $activePackage['name'] ?? '',
'price' => $activePackage['price'] ?? null,
'expiresAt' => $activePackage['expires_at'] ?? null,
'remainingEvents' => $activePackage['remaining_events'] ?? null,
] : null,
] : null,
'emailVerification' => [
'mustVerify' => $user instanceof MustVerifyEmail,
'verified' => $user?->hasVerifiedEmail() ?? false,
],
]);
}
private function collectUpcomingEvents(Tenant $tenant): Collection
{
return Event::query()
->where('tenant_id', $tenant->getKey())
->withCount(['photos', 'tasks', 'joinTokens'])
->orderByRaw('date IS NULL')
->orderBy('date')
->limit(5)
->get()
->map(function (Event $event): array {
return [
'id' => $event->id,
'name' => $this->resolveEventName($event),
'slug' => $event->slug,
'status' => $event->status,
'isActive' => (bool) $event->is_active,
'date' => optional($event->date)->toIso8601String(),
'photosCount' => (int) ($event->photos_count ?? 0),
'tasksCount' => (int) ($event->tasks_count ?? 0),
'joinTokensCount' => (int) ($event->join_tokens_count ?? 0),
];
});
}
private function collectRecentPurchases(Tenant $tenant): Collection
{
return $tenant->purchases()
->with('package')
->latest('purchased_at')
->limit(6)
->get()
->map(function (PackagePurchase $purchase): array {
return [
'id' => $purchase->id,
'packageName' => $purchase->package?->getNameForLocale(app()->getLocale())
?? $purchase->package?->name
?? __('Unknown package'),
'price' => $purchase->price !== null ? (float) $purchase->price : null,
'purchasedAt' => optional($purchase->purchased_at)->toIso8601String(),
'type' => $purchase->type,
'provider' => $purchase->provider,
];
});
}
private function resolveEventName(Event $event): string
{
$name = $event->name;
if (is_array($name)) {
$locale = app()->getLocale();
if (! empty($name[$locale])) {
return (string) $name[$locale];
}
foreach (['de', 'en'] as $fallback) {
if (! empty($name[$fallback])) {
return (string) $name[$fallback];
}
}
$firstTranslated = reset($name);
if (is_string($firstTranslated) && $firstTranslated !== '') {
return $firstTranslated;
}
}
if (is_string($name) && $name !== '') {
return $name;
}
return __('Untitled event');
}
}

View File

@@ -7,11 +7,13 @@ use App\Models\OAuthCode;
use App\Models\RefreshToken;
use App\Models\Tenant;
use App\Models\TenantToken;
use App\Models\User;
use App\Support\ApiError;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
@@ -35,6 +37,15 @@ class OAuthController extends Controller
*/
public function authorize(Request $request)
{
if (! Auth::check()) {
return $this->authorizeErrorResponse(
$request,
'login_required',
'Please sign in to continue.',
Response::HTTP_UNAUTHORIZED
);
}
$validator = Validator::make($request->all(), [
'client_id' => 'required|string',
'redirect_uri' => 'required|url',
@@ -46,7 +57,13 @@ class OAuthController extends Controller
]);
if ($validator->fails()) {
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
return $this->authorizeErrorResponse(
$request,
'invalid_request',
'The authorization request is invalid.',
Response::HTTP_BAD_REQUEST,
['errors' => $validator->errors()->toArray()]
);
}
/** @var OAuthClient|null $client */
@@ -56,23 +73,47 @@ class OAuthController extends Controller
->first();
if (! $client) {
return $this->errorResponse('Invalid client', 401);
return $this->authorizeErrorResponse(
$request,
'invalid_client',
'The specified client could not be found.',
Response::HTTP_UNAUTHORIZED
);
}
$allowedRedirects = (array) $client->redirect_uris;
if (! in_array($request->redirect_uri, $allowedRedirects, true)) {
return $this->errorResponse('Invalid redirect URI', 400);
return $this->authorizeErrorResponse(
$request,
'invalid_redirect',
'The redirect URI is not registered for this client.',
Response::HTTP_BAD_REQUEST
);
}
$requestedScopes = $this->parseScopes($request->string('scope'));
$availableScopes = (array) $client->scopes;
if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) {
return $this->errorResponse('Invalid scopes requested', 400);
return $this->authorizeErrorResponse(
$request,
'invalid_scope',
'The client requested scopes that are not permitted.',
Response::HTTP_BAD_REQUEST
);
}
$tenantId = $client->tenant_id ?? Tenant::query()->orderBy('id')->value('id');
/** @var User $user */
$user = Auth::user();
$tenantId = $this->resolveTenantId($client, $user);
if (! $tenantId) {
return $this->errorResponse('Unable to resolve tenant for client', 500);
return $this->authorizeErrorResponse(
$request,
'tenant_mismatch',
'You do not have access to the requested tenant.',
Response::HTTP_FORBIDDEN,
['client_id' => $client->client_id]
);
}
$code = Str::random(64);
@@ -95,7 +136,7 @@ class OAuthController extends Controller
OAuthCode::create([
'id' => $codeId,
'client_id' => $client->client_id,
'user_id' => (string) $tenantId,
'user_id' => (string) $user->getAuthIdentifier(),
'code' => Hash::make($code),
'code_challenge' => $request->code_challenge,
'state' => $request->state,
@@ -120,6 +161,75 @@ class OAuthController extends Controller
return redirect()->away($redirectUrl);
}
private function resolveTenantId(OAuthClient $client, User $user): ?int
{
if ($client->tenant_id !== null) {
if ((int) $client->tenant_id === (int) ($user->tenant_id ?? 0) || $user->role === 'super_admin') {
return (int) $client->tenant_id;
}
return null;
}
if ($user->tenant_id !== null) {
return (int) $user->tenant_id;
}
return null;
}
private function rememberIntendedUrl(Request $request): void
{
session()->put('url.intended', $request->fullUrl());
}
private function authorizeErrorResponse(
Request $request,
string $code,
string $message,
int $status,
array $meta = []
) {
$this->rememberIntendedUrl($request);
if ($this->shouldReturnJsonAuthorizeResponse($request)) {
$payload = [
'error' => $code,
'error_description' => $message,
];
if ($meta !== []) {
$payload['meta'] = $meta;
}
return response()->json($payload, $status);
}
$query = [
'error' => $code,
'error_description' => $message,
'return_to' => $this->encodeReturnTo($request->fullUrl()),
];
if ($meta !== []) {
$metaJson = json_encode($meta, JSON_UNESCAPED_SLASHES);
if ($metaJson !== false) {
$query['error_meta'] = $this->encodeReturnTo($metaJson);
}
}
$redirectUrl = route('tenant.admin.login').'?'.http_build_query($query);
return redirect()->to($redirectUrl);
}
private function encodeReturnTo(?string $value): string
{
$value ??= '';
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
/**
* Token endpoint - Code exchange & refresh
*/

View File

@@ -2,45 +2,73 @@
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\Auth;
use Inertia\Response;
class ProfileController extends Controller
{
public function index()
public function index(Request $request): Response
{
$user = Auth::user()->load('purchases.packages');
$user = $request->user()
->load(['tenant' => function ($query) {
$query->with([
'purchases' => fn ($purchases) => $purchases
->with('package')
->latest('purchased_at')
->limit(10),
'tenantPackages' => fn ($packages) => $packages
->with('package')
->orderByDesc('active')
->orderByDesc('purchased_at'),
]);
}]);
$tenant = $user->tenant;
$activePackage = $tenant?->tenantPackages
?->first(fn ($package) => (bool) $package->active);
$purchases = $tenant?->purchases
?->map(fn ($purchase) => [
'id' => $purchase->id,
'packageName' => $purchase->package?->getNameForLocale(app()->getLocale())
?? $purchase->package?->name
?? __('Unknown package'),
'price' => $purchase->price !== null ? (float) $purchase->price : null,
'purchasedAt' => optional($purchase->purchased_at)->toIso8601String(),
'type' => $purchase->type,
'provider' => $purchase->provider,
])
->values()
->all();
return Inertia::render('Profile/Index', [
'user' => $user,
]);
}
public function account()
{
$user = Auth::user()->load('purchases.packages');
if (request()->isMethod('post')) {
$validated = request()->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id,
]);
$user->update($validated);
return back()->with('success', 'Profil aktualisiert.');
}
return Inertia::render('Profile/Account', [
'user' => $user,
]);
}
public function orders()
{
$user = Auth::user()->load('purchases.packages');
return Inertia::render('Profile/Orders', [
'purchases' => $user->purchases,
'userData' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'username' => $user->username,
'preferredLocale' => $user->preferred_locale,
'emailVerifiedAt' => optional($user->email_verified_at)->toIso8601String(),
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
],
'tenant' => $tenant ? [
'id' => $tenant->id,
'name' => $tenant->name,
'eventCreditsBalance' => $tenant->event_credits_balance,
'subscriptionStatus' => $tenant->subscription_status,
'subscriptionExpiresAt' => optional($tenant->subscription_expires_at)->toIso8601String(),
'activePackage' => $activePackage ? [
'name' => $activePackage->package?->getNameForLocale(app()->getLocale())
?? $activePackage->package?->name
?? __('Unknown package'),
'price' => $activePackage->price !== null ? (float) $activePackage->price : null,
'expiresAt' => optional($activePackage->expires_at)->toIso8601String(),
'remainingEvents' => $activePackage->remaining_events ?? null,
] : null,
] : null,
'purchases' => $purchases,
]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Throwable;
class TenantAdminGoogleController extends Controller
{
public function redirect(Request $request): RedirectResponse
{
$returnTo = $request->query('return_to');
if (is_string($returnTo) && $returnTo !== '') {
$request->session()->put('tenant_oauth_return_to', $returnTo);
}
return Socialite::driver('google')
->scopes(['openid', 'profile', 'email'])
->with(['prompt' => 'select_account'])
->redirect();
}
public function callback(Request $request)
{
try {
$googleUser = Socialite::driver('google')->user();
} catch (Throwable $exception) {
Log::warning('Tenant admin Google sign-in failed', [
'message' => $exception->getMessage(),
]);
return $this->sendBackWithError($request, 'google_failed', 'Unable to complete Google sign-in.');
}
$email = $googleUser->getEmail();
if (! $email) {
return $this->sendBackWithError($request, 'google_failed', 'Google account did not provide an email address.');
}
/** @var User|null $user */
$user = User::query()->where('email', $email)->first();
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
}
$user->forceFill([
'name' => $googleUser->getName() ?: $user->name,
'email_verified_at' => $user->email_verified_at ?? now(),
])->save();
Auth::login($user, true);
$request->session()->regenerate();
$returnTo = $request->session()->pull('tenant_oauth_return_to');
if (is_string($returnTo)) {
$decoded = $this->decodeReturnTo($returnTo, $request);
if ($decoded) {
return redirect()->to($decoded);
}
}
return redirect()->intended(route('tenant.admin.app'));
}
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
{
$query = [
'error' => $code,
'error_description' => $message,
];
if ($request->session()->has('tenant_oauth_return_to')) {
$query['return_to'] = $request->session()->get('tenant_oauth_return_to');
}
return redirect()->route('tenant.admin.login', $query);
}
private function decodeReturnTo(string $encoded, Request $request): ?string
{
$padded = str_pad($encoded, strlen($encoded) + ((4 - (strlen($encoded) % 4)) % 4), '=');
$normalized = strtr($padded, '-_', '+/');
$decoded = base64_decode($normalized);
if (! is_string($decoded) || $decoded === '') {
return null;
}
$targetHost = parse_url($decoded, PHP_URL_HOST);
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
return null;
}
return $decoded;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Tenant;
use App\Support\TenantAuth;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class ProfileUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$userId = null;
try {
$userId = TenantAuth::resolveAdminUser($this)->getKey();
} catch (\Throwable $e) {
$userId = null;
}
$supportedLocales = config('app.supported_locales');
if (! is_array($supportedLocales) || empty($supportedLocales)) {
$supportedLocales = array_filter([
config('app.locale', 'de'),
config('app.fallback_locale', 'en'),
]);
}
return [
'name' => ['required', 'string', 'max:120'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($userId),
],
'preferred_locale' => [
'nullable',
'string',
Rule::in($supportedLocales),
],
'current_password' => ['required_with:password', 'string'],
'password' => ['nullable', Password::defaults(), 'confirmed'],
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Services\Tenant;
use App\Models\Event;
use App\Models\Photo;
use App\Models\Tenant;
use App\Models\TenantPackage;
use Illuminate\Support\Carbon;
class DashboardSummaryService
{
public function build(Tenant $tenant): array
{
$eventsQuery = Event::query()
->where('tenant_id', $tenant->getKey());
$totalEvents = (clone $eventsQuery)->count();
$activeEvents = (clone $eventsQuery)
->where(static function ($query) {
$query->where('is_active', true)
->orWhere('status', 'published');
})
->count();
$publishedEvents = (clone $eventsQuery)
->where('status', 'published')
->count();
$eventsWithTasks = (clone $eventsQuery)
->whereHas('tasks')
->count();
$upcomingEvents = (clone $eventsQuery)
->whereDate('date', '>=', Carbon::now()->startOfDay())
->count();
$newPhotos = Photo::query()
->whereHas('event', static function ($query) use ($tenant) {
$query->where('tenant_id', $tenant->getKey());
})
->where('created_at', '>=', Carbon::now()->subDays(7))
->count();
/** @var TenantPackage|null $activePackage */
$activePackage = $tenant->tenantPackages()
->with('package')
->where('active', true)
->orderByDesc('expires_at')
->orderByDesc('purchased_at')
->first();
return [
'total_events' => $totalEvents,
'active_events' => $activeEvents,
'published_events' => $publishedEvents,
'events_with_tasks' => $eventsWithTasks,
'upcoming_events' => $upcomingEvents,
'new_photos' => $newPhotos,
'task_progress' => $totalEvents > 0
? (int) round(($eventsWithTasks / $totalEvents) * 100)
: 0,
'credit_balance' => $tenant->event_credits_balance ?? null,
'active_package' => $activePackage ? [
'name' => $activePackage->package?->getNameForLocale(app()->getLocale())
?? $activePackage->package?->name
?? '',
'expires_at' => optional($activePackage->expires_at)->toIso8601String(),
'remaining_events' => $activePackage->remaining_events ?? null,
'price' => $activePackage->price !== null ? (float) $activePackage->price : null,
] : null,
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Support;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class TenantAuth
{
/**
* Resolve the tenant admin user associated with the current request.
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function resolveAdminUser(Request $request): User
{
$decoded = (array) $request->attributes->get('decoded_token', []);
$tenantId = $request->attributes->get('tenant_id')
?? $request->input('tenant_id')
?? Arr::get($decoded, 'tenant_id');
if (! $tenantId) {
throw (new ModelNotFoundException)->setModel(User::class);
}
$userId = Arr::get($decoded, 'user_id');
if ($userId) {
$user = User::query()
->whereKey($userId)
->where('tenant_id', $tenantId)
->first();
if ($user) {
return $user;
}
}
$user = User::query()
->where('tenant_id', $tenantId)
->whereIn('role', ['tenant_admin', 'admin'])
->orderByDesc('email_verified_at')
->orderBy('id')
->first();
if (! $user) {
throw (new ModelNotFoundException)->setModel(User::class);
}
return $user;
}
}

View File

@@ -17,19 +17,11 @@ class DemoAchievementsSeeder extends Seeder
{
public function run(): void
{
$event = Event::where('slug', 'demo-wedding-2025')->first();
$tenant = Tenant::where('slug', 'demo')->first();
$tenant = Tenant::where('slug', 'demo-tenant')->first();
if (! $event || ! $tenant) {
$this->command?->warn('Demo event/tenant missing skipping DemoAchievementsSeeder');
return;
}
if (! $tenant) {
$this->command?->warn('Demo tenant missing skipping DemoAchievementsSeeder');
$tasks = Task::where('tenant_id', $tenant->id)->pluck('id')->all();
$emotions = Emotion::pluck('id')->all();
if ($tasks === [] || $emotions === []) {
$this->command?->warn('Tasks or emotions missing skipping DemoAchievementsSeeder');
return;
}
@@ -39,44 +31,98 @@ class DemoAchievementsSeeder extends Seeder
if ($sourceFiles->isEmpty()) {
$this->command?->warn('No demo photo files found skipping DemoAchievementsSeeder');
return;
}
$blueprints = [
$emotions = Emotion::pluck('id')->all();
if ($emotions === []) {
$this->command?->warn('No emotions available skipping DemoAchievementsSeeder');
return;
}
$scenarios = [
[
'event' => Event::with(['tasks', 'eventType'])
->where('slug', 'demo-wedding-2025')
->first(),
'blueprints' => [
['guest' => 'Anna Mueller', 'photos' => 6, 'likes' => [12, 8, 5, 4, 2, 1], 'withTasks' => true],
['guest' => 'Max Schmidt', 'photos' => 4, 'likes' => [9, 7, 4, 2], 'withTasks' => true],
['guest' => 'Lisa Weber', 'photos' => 2, 'likes' => [3, 1], 'withTasks' => false],
['guest' => 'Tom Fischer', 'photos' => 1, 'likes' => [14], 'withTasks' => true],
['guest' => 'Team Brautparty', 'photos' => 5, 'likes' => [5, 4, 3, 3, 2], 'withTasks' => true],
],
],
[
'event' => Event::with(['tasks', 'eventType'])
->where('slug', 'demo-corporate-2025')
->first(),
'blueprints' => [
['guest' => 'HR Dream Team', 'photos' => 4, 'likes' => [8, 6, 4, 3], 'withTasks' => true],
['guest' => 'Innovation Squad', 'photos' => 5, 'likes' => [10, 7, 5, 4, 2], 'withTasks' => true],
['guest' => 'Finance Crew', 'photos' => 3, 'likes' => [6, 5, 2], 'withTasks' => false],
['guest' => 'New Joiners', 'photos' => 4, 'likes' => [5, 4, 3, 2], 'withTasks' => true],
],
],
];
foreach ($scenarios as $scenario) {
/** @var Event|null $event */
$event = $scenario['event'];
if (! $event) {
$this->command?->warn('Demo event missing skipping achievements for one scenario.');
continue;
}
$taskIds = $event->tasks()->pluck('tasks.id')->all();
if ($taskIds === []) {
$taskIds = Task::where('event_type_id', $event->event_type_id ?? optional($event->eventType)->id)
->pluck('id')
->all();
}
if ($taskIds === []) {
$this->command?->warn(sprintf('No tasks available for %s achievements will use taskless entries.', $event->slug));
}
$eventDate = $event->date ? CarbonImmutable::parse($event->date) : CarbonImmutable::now();
$baseDir = "events/{$event->id}/achievements";
Storage::disk('public')->makeDirectory($baseDir);
Storage::disk('public')->makeDirectory("{$baseDir}/thumbs");
$photoIndex = 0;
foreach ($blueprints as $groupIndex => $blueprint) {
foreach ($scenario['blueprints'] as $groupIndex => $blueprint) {
for ($i = 0; $i < $blueprint['photos']; $i++) {
$source = $sourceFiles[$photoIndex % $sourceFiles->count()];
$sourcePath = $sourceFiles[$photoIndex % $sourceFiles->count()];
$photoIndex++;
$filename = Str::slug($blueprint['guest'].'-'.$groupIndex.'-'.$i).'.jpg';
$destPath = "{$baseDir}/{$filename}";
if (! Storage::disk('public')->exists($destPath)) {
Storage::disk('public')->copy($source, $destPath);
Storage::disk('public')->copy($sourcePath, $destPath);
}
$thumbSource = str_replace('photos/', 'thumbnails/', $source);
$thumbSource = str_replace('photos/', 'thumbnails/', $sourcePath);
$thumbDest = "{$baseDir}/thumbs/{$filename}";
if (Storage::disk('public')->exists($thumbSource)) {
Storage::disk('public')->copy($thumbSource, $thumbDest);
} else {
Storage::disk('public')->copy($source, $thumbDest);
Storage::disk('public')->copy($sourcePath, $thumbDest);
}
$taskId = null;
if (! empty($taskIds) && ($blueprint['withTasks'] ?? false)) {
$taskId = $taskIds[($groupIndex + $i) % count($taskIds)];
}
$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;
@@ -94,7 +140,7 @@ class DemoAchievementsSeeder extends Seeder
'thumbnail_path' => $thumbDest,
'likes_count' => $likes,
'is_featured' => $i === 0,
'metadata' => ['demo' => true],
'metadata' => ['demo' => true, 'achievement' => true],
'created_at' => $createdAt,
'updated_at' => $createdAt,
]
@@ -112,6 +158,7 @@ class DemoAchievementsSeeder extends Seeder
}
}
$this->command?->info('Demo achievements seeded.');
$this->command?->info(sprintf('Demo achievements seeded for %s.', $event->slug));
}
}
}

View File

@@ -7,62 +7,131 @@ use App\Models\EventPackage;
use App\Models\EventType;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Services\EventJoinTokenService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
class DemoEventSeeder extends Seeder
{
public function run(): void
{
$type = EventType::where('slug', 'wedding')->first();
if (! $type) {
return;
}
$demoTenant = Tenant::where('slug', 'demo-tenant')->first();
if (! $demoTenant) {
return;
}
$event = Event::updateOrCreate(['slug' => 'demo-wedding-2025'], [
'tenant_id' => $demoTenant->id,
$weddingType = EventType::where('slug', 'wedding')->first();
$corporateType = EventType::where('slug', 'corporate')->first();
$standardPackage = Package::where('slug', 'standard')->first()
?? Package::where('type', 'endcustomer')->orderBy('price')->first();
$premiumPackage = Package::where('slug', 'premium')->first()
?? Package::where('type', 'endcustomer')->orderByDesc('price')->first();
$events = [
[
'slug' => 'demo-wedding-2025',
'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'],
'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'],
'date' => now()->addMonths(3)->toDateString(),
'event_type_id' => $type->id,
'date' => Carbon::now()->addMonths(3),
'event_type' => $weddingType,
'package' => $standardPackage,
'token_label' => 'Demo QR',
'collection_slugs' => ['wedding-classics-2025'],
'task_slug_prefix' => 'wedding-',
'branding' => [
'primary_color' => '#f43f5e',
'secondary_color' => '#fb7185',
'background_color' => '#fff7f4',
'font_family' => 'Playfair Display, serif',
],
],
[
'slug' => 'demo-corporate-2025',
'name' => ['de' => 'Demo Firmen-Event 2025', 'en' => 'Demo Corporate Summit 2025'],
'description' => ['de' => 'Launch-Event mit Networking', 'en' => 'Launch event with networking sessions'],
'date' => Carbon::now()->addMonths(2),
'event_type' => $corporateType,
'package' => $premiumPackage,
'token_label' => 'Corporate QR',
'collection_slugs' => ['corporate-classics-2025'],
'task_slug_prefix' => 'corporate-',
'branding' => [
'primary_color' => '#0ea5e9',
'secondary_color' => '#2563eb',
'background_color' => '#0f172a',
'font_family' => 'Inter, sans-serif',
],
],
];
foreach ($events as $config) {
if (! $config['event_type'] || ! $config['package']) {
continue;
}
$event = Event::updateOrCreate(
['slug' => $config['slug']],
[
'tenant_id' => $demoTenant->id,
'name' => $config['name'],
'description' => $config['description'],
'date' => $config['date']->toDateString(),
'event_type_id' => $config['event_type']->id,
'status' => 'published',
'is_active' => true,
'settings' => json_encode([]),
'settings' => [
'branding' => $config['branding'],
],
'default_locale' => 'de',
]);
]
);
if ($event->joinTokens()->count() === 0) {
/** @var EventJoinTokenService $service */
$service = app(EventJoinTokenService::class);
$service->createToken($event, [
'label' => 'Demo QR',
]);
$this->ensureJoinToken($event, $config['token_label']);
$this->attachEventPackage(
event: $event,
package: $config['package'],
tenant: $demoTenant,
providerId: 'demo-seed-'.$config['slug'],
purchasedAt: Carbon::now()->subDays(7)
);
$this->attachTaskCollections($event, $config['collection_slugs']);
$this->attachEventTasks($event, $config['task_slug_prefix']);
}
}
$package = Package::where('slug', 'standard')->first();
if (! $package) {
$package = Package::where('type', 'endcustomer')->orderBy('price')->first();
private function ensureJoinToken(Event $event, string $label): void
{
if ($event->joinTokens()->exists()) {
return;
}
if ($package) {
app(EventJoinTokenService::class)->createToken($event, ['label' => $label]);
}
private function attachEventPackage(Event $event, Package $package, Tenant $tenant, string $providerId, Carbon $purchasedAt): void
{
$eventPackageData = [
'purchased_price' => $package->price,
'purchased_at' => now()->subDays(7),
'purchased_at' => $purchasedAt,
];
if (Schema::hasColumn('event_packages', 'used_photos')) {
$eventPackageData['used_photos'] = 0;
}
if (Schema::hasColumn('event_packages', 'used_guests')) {
$eventPackageData['used_guests'] = 0;
}
if (Schema::hasColumn('event_packages', 'gallery_expires_at')) {
$eventPackageData['gallery_expires_at'] = now()->addDays($package->gallery_days ?? 30);
$eventPackageData['gallery_expires_at'] = $purchasedAt->copy()->addDays($package->gallery_days ?? 30);
}
EventPackage::updateOrCreate(
@@ -73,13 +142,81 @@ class DemoEventSeeder extends Seeder
$eventPackageData
);
PackagePurchase::query()
->where('tenant_id', $demoTenant->id)
->where('package_id', $package->id)
->where('provider_id', 'demo-seed')
->update([
PackagePurchase::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $providerId,
],
[
'event_id' => $event->id,
]);
'price' => $package->price,
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
'purchased_at' => $purchasedAt,
'metadata' => ['demo' => true, 'event_slug' => $event->slug],
'ip_address' => null,
'user_agent' => null,
]
);
}
private function attachTaskCollections(Event $event, array $collectionSlugs): void
{
if ($collectionSlugs === []) {
return;
}
$collections = TaskCollection::whereIn('slug', $collectionSlugs)->get();
$pivot = [];
foreach ($collections as $index => $collection) {
$pivot[$collection->id] = ['sort_order' => ($index + 1) * 10];
}
if ($pivot !== []) {
$event->taskCollections()->syncWithoutDetaching($pivot);
}
}
private function attachEventTasks(Event $event, string $slugPrefix): void
{
$tasks = [];
if ($event->event_type_id) {
$tasks = Task::where('event_type_id', $event->event_type_id)
->orderBy('sort_order')
->limit(25)
->pluck('id')
->all();
}
if ($tasks === [] && $slugPrefix !== '') {
$tasks = Task::where('slug', 'like', $slugPrefix.'%')
->orderBy('sort_order')
->limit(25)
->pluck('id')
->all();
}
if ($tasks === []) {
$tasks = Task::where('tenant_id', $event->tenant_id)
->orderBy('sort_order')
->limit(20)
->pluck('id')
->all();
}
if ($tasks === []) {
return;
}
$tasks = array_slice(array_unique($tasks), 0, 20);
$pivot = [];
foreach ($tasks as $index => $taskId) {
$pivot[$taskId] = ['sort_order' => ($index + 1) * 10];
}
$event->tasks()->syncWithoutDetaching($pivot);
}
}

View File

@@ -2,101 +2,189 @@
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use App\Models\{Event, Task, Emotion, Photo, PhotoLike, Tenant};
use Illuminate\Support\Facades\File;
use App\Models\Emotion;
use App\Models\Event;
use App\Models\Photo;
use App\Models\PhotoLike;
use App\Models\Task;
use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class DemoPhotosSeeder extends Seeder
{
public function run(): void
{
// Get demo event and tenant
$demoEvent = Event::where('slug', 'demo-wedding-2025')->first();
$demoTenant = Tenant::where('slug', 'demo')->first();
$tenant = Tenant::where('slug', 'demo-tenant')->first();
if (! $tenant) {
$this->command->info('Demo tenant not found, skipping DemoPhotosSeeder');
return;
}
$photoDir = storage_path('app/public/photos');
if (! File::exists($photoDir)) {
$this->command->info('No demo photos available in storage/app/public/photos');
return;
}
$photoFiles = collect(File::files($photoDir))
->filter(fn ($file) => str_ends_with(strtolower($file->getFilename()), '.jpg'))
->values();
if ($photoFiles->isEmpty()) {
$this->command->info('No JPG demo photos found, skipping DemoPhotosSeeder');
if (!$demoEvent || !$demoTenant) {
$this->command->info('Demo event or tenant not found, skipping DemoPhotosSeeder');
return;
}
// Get all available tasks and emotions
$tasks = Task::where('tenant_id', $demoTenant->id)->get();
$emotions = Emotion::all();
if ($emotions->isEmpty()) {
$this->command->info('No emotions available, skipping DemoPhotosSeeder');
if ($tasks->isEmpty() || $emotions->isEmpty()) {
$this->command->info('No tasks or emotions found, skipping DemoPhotosSeeder');
return;
}
// List of 20 guest names (ASCII only to avoid encoding issues)
$guestNames = [
$events = [
[
'model' => Event::with(['tasks', 'eventPackage', 'eventPackages', 'eventType'])
->where('slug', 'demo-wedding-2025')
->first(),
'guest_names' => [
'Anna Mueller', 'Max Schmidt', 'Lisa Weber', 'Tom Fischer', 'Sophie Bauer',
'Lukas Hoffmann', 'Emma Wagner', 'Jonas Klein', 'Mia Schwarz', 'Felix Becker',
'Lena Richter', 'Paul Lehmann', 'Julia Neumann', 'David Vogel', 'Sara Krueger',
'Tim Berger', 'Nina Wolf', 'Ben Schaefer', 'Laura Stein', 'Moritz Fuchs'
'Tim Berger', 'Nina Wolf', 'Ben Schaefer', 'Laura Stein', 'Moritz Fuchs',
],
'like_range' => [4, 18],
],
[
'model' => Event::with(['tasks', 'eventPackage', 'eventPackages', 'eventType'])
->where('slug', 'demo-corporate-2025')
->first(),
'guest_names' => [
'Clara Schmidt', 'Jan Becker', 'Noah Winkler', 'Sina Albrecht', 'Kai Lenz',
'Tara Nguyen', 'Omar Hassan', 'Elias Roth', 'Greta Sommer', 'Leonard Busch',
'Verena Graf', 'Nico Adler', 'Johanna Kurz', 'Fabian Scholz', 'Mara Kranz',
'Yuki Tanaka', 'Mateo Ruiz', 'Amina Korb', 'Philipp Krüger', 'Selma Vogt',
],
'like_range' => [2, 12],
],
];
// Get all photo files from storage
$photoDir = storage_path('app/public/photos');
$photoFiles = File::files($photoDir);
foreach ($events as $config) {
/** @var Event|null $event */
$event = $config['model'];
if (! $event) {
$this->command->warn('Demo event missing, skipping photo seeding.');
$seededCount = 0;
foreach ($photoFiles as $file) {
$filename = $file->getFilename();
if (!str_ends_with($filename, '.jpg')) {
continue;
}
// Check if already seeded (avoid duplicates)
if (Photo::where('file_path', 'photos/' . $filename)->exists()) {
$taskIds = $event->tasks()->pluck('tasks.id')->all();
if ($taskIds === []) {
$eventTypeId = $event->event_type_id ?? optional($event->eventType)->id;
$taskIds = Task::where('event_type_id', $eventTypeId)->pluck('id')->all();
}
if ($taskIds === []) {
$this->command->warn(sprintf('No tasks assigned to %s. Photos will be seeded without task references.', $event->slug));
}
$guestNames = $config['guest_names'];
$photosToSeed = min($photoFiles->count(), count($guestNames));
if ($photosToSeed === 0) {
continue;
}
// Generate thumbnail path
$thumbnailFilename = str_replace('.jpg', '_thumb.jpg', $filename);
$thumbnailPath = 'thumbnails/' . $thumbnailFilename;
$storage = Storage::disk('public');
$storage->makeDirectory("events/{$event->id}/gallery");
$storage->makeDirectory("events/{$event->id}/gallery/thumbs");
// Random assignments
$randomTask = $tasks->random();
$randomEmotion = $emotions->random();
$randomGuest = $guestNames[array_rand($guestNames)];
$randomLikes = rand(0, 20);
$eventDate = $demoEvent->date;
$randomUploadedAt = Carbon::parse($eventDate)->addHours(rand(0, 24))->addMinutes(rand(0, 59));
$photosSeeded = 0;
// Create photo
$photo = Photo::create([
'tenant_id' => $demoTenant->id,
'event_id' => $demoEvent->id,
'task_id' => $randomTask->id,
'emotion_id' => $randomEmotion->id,
'guest_name' => $randomGuest,
'file_path' => 'photos/' . $filename,
'thumbnail_path' => $thumbnailPath,
'likes_count' => $randomLikes,
'is_featured' => false,
'metadata' => [],
'created_at' => $randomUploadedAt,
'updated_at' => $randomUploadedAt,
]);
for ($i = 0; $i < $photosToSeed; $i++) {
$sourceFile = $photoFiles->get($i % $photoFiles->count());
$baseName = pathinfo($sourceFile->getFilename(), PATHINFO_FILENAME);
$guestName = $guestNames[$i];
$likes = rand($config['like_range'][0], $config['like_range'][1]);
$timestamp = Carbon::parse($event->date ?? now())
->addHours(rand(0, 36))
->addMinutes(rand(0, 59));
// Add random likes
if ($randomLikes > 0) {
for ($i = 0; $i < $randomLikes; $i++) {
$filename = sprintf('%s-demo-%02d.jpg', $event->slug, $i + 1);
$destPath = "events/{$event->id}/gallery/{$filename}";
if (! $storage->exists($destPath)) {
$storage->put($destPath, File::get($sourceFile->getRealPath()));
}
$thumbFilename = sprintf('%s-demo-%02d_thumb.jpg', $event->slug, $i + 1);
$thumbDest = "events/{$event->id}/gallery/thumbs/{$thumbFilename}";
$existingThumb = "thumbnails/{$baseName}_thumb.jpg";
if ($storage->exists($existingThumb)) {
if (! $storage->exists($thumbDest)) {
$storage->copy($existingThumb, $thumbDest);
}
} else {
if (! $storage->exists($thumbDest)) {
$storage->put($thumbDest, File::get($sourceFile->getRealPath()));
}
}
$taskId = $taskIds ? $taskIds[array_rand($taskIds)] : null;
$emotionId = $emotions->random()->id;
$photo = Photo::updateOrCreate(
[
'tenant_id' => $tenant->id,
'event_id' => $event->id,
'file_path' => $destPath,
],
[
'task_id' => $taskId,
'emotion_id' => $emotionId,
'guest_name' => $guestName,
'thumbnail_path' => $thumbDest,
'likes_count' => $likes,
'is_featured' => $i === 0,
'metadata' => ['demo' => true],
'created_at' => $timestamp,
'updated_at' => $timestamp,
]
);
PhotoLike::where('photo_id', $photo->id)->delete();
$maxLikes = min($likes, 10);
for ($like = 0; $like < $maxLikes; $like++) {
PhotoLike::create([
'photo_id' => $photo->id,
'guest_name' => 'GuestLike_'.Str::random(6),
'ip_address' => '10.0.'.rand(0, 254).'.'.rand(1, 254),
'created_at' => $randomUploadedAt->clone()->addMinutes(rand(0, 60)),
'created_at' => $timestamp->copy()->addMinutes($like * 3),
]);
}
$photosSeeded++;
}
$seededCount++;
$eventPackage = $event->eventPackage ?? $event->eventPackages()->orderByDesc('purchased_at')->first();
if ($eventPackage) {
$eventPackage->forceFill([
'used_photos' => max($eventPackage->used_photos ?? 0, $photosSeeded),
'used_guests' => max($eventPackage->used_guests ?? 0, count(array_unique($guestNames))),
])->save();
}
$this->command->info(sprintf('Seeded %d demo photos with random tasks, emotions, uploaders, and likes', $seededCount));
$this->command->info(sprintf('Seeded %d demo photos for %s', $photosSeeded, $event->slug));
}
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class E2ETenantSeeder extends Seeder
{
public function run(): void
{
= config('testing.e2e_tenant_email', env('E2E_TENANT_EMAIL', 'tenant-e2e@example.com'));
= config('testing.e2e_tenant_password', env('E2E_TENANT_PASSWORD', 'password123'));
= User::firstOrNew(['email' => ]);
->fill([
'name' => ->name ?? 'E2E Tenant Admin',
'first_name' => ->first_name ?? 'E2E',
'last_name' => ->last_name ?? 'Admin',
'role' => 'tenant_admin',
'pending_purchase' => false,
]);
->password = Hash::make();
->email_verified_at = now();
->save();
= Tenant::firstOrNew(['user_id' => ->id]);
->fill([
'name' => ->name ?? 'E2E Test Tenant',
'slug' => ->slug ?? Str::slug('e2e-tenant-' . ->id),
'email' => ,
'is_active' => true,
'is_suspended' => false,
'event_credits_balance' => ->event_credits_balance ?? 1,
'subscription_status' => ->subscription_status ?? 'active',
'settings' => ->settings ?? [
'branding' => [
'logo_url' => null,
'primary_color' => '#ef476f',
'secondary_color' => '#ffd166',
'font_family' => 'Montserrat, sans-serif',
],
'features' => [
'photo_likes_enabled' => true,
],
'contact_email' => ,
'event_default_type' => 'general',
],
]);
->save();
}
}

View File

@@ -2,18 +2,20 @@
namespace Database\Seeders;
use App\Models\Emotion;
use App\Models\Task;
use App\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use App\Models\{Emotion, Task, Tenant};
class EventTasksSeeder extends Seeder
{
public function run(): void
{
// Get demo tenant
$demoTenant = Tenant::where('slug', 'demo')->first();
$demoTenant = Tenant::where('slug', 'demo-tenant')->first();
if (! $demoTenant) {
$this->command->info('Demo tenant not found, skipping EventTasksSeeder');
return;
}
@@ -203,9 +205,12 @@ class EventTasksSeeder extends Seeder
foreach (Emotion::all() as $emotion) {
$name = is_array($emotion->name) ? ($emotion->name['de'] ?? array_values($emotion->name)[0]) : (string) $emotion->name;
$list = $catalog[$name] ?? null;
if (!$list) continue; // skip unknown emotion labels
if (! $list) {
continue;
} // skip unknown emotion labels
$created = 0; $order = 1;
$created = 0;
$order = 1;
foreach ($list as $i => $row) {
[$deTitle, $deDesc, $enTitle, $enDesc] = $row;
@@ -213,7 +218,11 @@ class EventTasksSeeder extends Seeder
$exists = Task::where('emotion_id', $emotion->id)
->where('title->de', $deTitle)
->exists();
if ($exists) { $order++; continue; }
if ($exists) {
$order++;
continue;
}
Task::create([
'tenant_id' => $demoTenant->id,
@@ -245,9 +254,9 @@ class EventTasksSeeder extends Seeder
'sort_order' => $order++,
'is_active' => true,
]);
$created++; $i++;
$created++;
$i++;
}
}
}
}

View File

@@ -6,6 +6,7 @@ use App\Models\Emotion;
use App\Models\EventType;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
@@ -13,18 +14,13 @@ class TasksSeeder extends Seeder
{
public function run(): void
{
// Create or get demo tenant
$demoTenant = \App\Models\Tenant::updateOrCreate(
['slug' => 'demo'],
[
'name' => 'Demo Tenant',
'domain' => null,
'is_active' => true,
'is_suspended' => false,
'settings' => json_encode([]),
'settings_updated_at' => null,
]
);
$demoTenant = Tenant::where('slug', 'demo-tenant')->first();
if (! $demoTenant) {
$this->command?->warn('Demo tenant not found, skipping TasksSeeder');
return;
}
$seed = [
'Liebe' => [

View File

@@ -2,15 +2,19 @@
namespace Database\Seeders;
use App\Models\Emotion;
use App\Models\EventType;
use App\Models\Task;
use Illuminate\Database\Seeder;
use App\Models\{Emotion, Task, EventType};
class WeddingTasksSeeder extends Seeder
{
public function run(): void
{
$weddingType = EventType::where('slug', 'wedding')->first();
if (!$weddingType) return;
if (! $weddingType) {
return;
}
// Helper to resolve emotion by English name (more stable given encoding issues)
$by = fn (string $en) => Emotion::where('name->en', $en)->first();
@@ -88,7 +92,9 @@ class WeddingTasksSeeder extends Seeder
$sort = 1;
foreach ($tasks as [$emotion, $titleDe, $titleEn, $descDe, $descEn, $difficulty]) {
if (!$emotion) continue;
if (! $emotion) {
continue;
}
Task::updateOrCreate([
'emotion_id' => $emotion->id,
'title->de' => $titleDe,
@@ -104,4 +110,3 @@ class WeddingTasksSeeder extends Seeder
}
}
}

View File

@@ -1,166 +1,55 @@
{
"login_failed": "Diese Anmeldedaten wurden nicht gefunden.",
"login_success": "Sie sind nun eingeloggt.",
"registration_failed": "Registrierung fehlgeschlagen.",
"registration_success": "Registrierung erfolgreich bitte mit dem Kauf fortfahren.",
"already_logged_in": "Sie sind bereits eingeloggt.",
"failed_credentials": "Diese Anmeldedaten wurden nicht gefunden.",
"header": {
"login": "Anmelden",
"register": "Registrieren",
"home": "Startseite",
"how_it_works": "So geht's",
"packages": "Pakete",
"blog": "Blog",
"occasions": {
"wedding": "Hochzeiten",
"birthday": "Geburtstage",
"corporate": "Firmenfeiern",
"confirmation": "Konfirmation/Jugendweihe",
"label": "Anlässe"
"wedding": "Hochzeit",
"birthday": "Geburtstag",
"corporate": "Firmenevent"
},
"contact": "Kontakt",
"login": "Anmelden",
"register": "Registrieren",
"cta": "Jetzt ausprobieren",
"utility": "Darstellung & Sprache",
"appearance": "Darstellung",
"appearance_light": "Hell",
"appearance_dark": "Dunkel",
"language": "Sprache"
"contact": "Kontakt"
},
"login_failed": "Ungültige E-Mail oder Passwort.",
"login_success": "Sie sind nun eingeloggt.",
"registration_failed": "Registrierung fehlgeschlagen.",
"registration_success": "Registrierung erfolgreich fortfahren mit Kauf.",
"already_logged_in": "Sie sind bereits eingeloggt.",
"failed_credentials": "Falsche Anmeldedaten.",
"login": {
"title": "Anmelden",
"description": "Melde dich mit deinem Fotospiel Konto an und begleite dein Event vom ersten Upload bis zum finalen Download.",
"identifier": "E-Mail oder Username",
"identifier_placeholder": "Geben Sie Ihre E-Mail oder Ihren Username ein",
"title": "Die Fotospiel.App",
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel.App",
"logo_alt": "Logo Die Fotospiel.App",
"username_or_email": "Username oder E-Mail",
"email": "E-Mail-Adresse",
"email_placeholder": "Deine E-Mail-Adresse",
"email_placeholder": "ihre@email.de",
"password": "Passwort",
"password_placeholder": "Geben Sie Ihr Passwort ein",
"forgot": "Passwort vergessen?",
"password_placeholder": "Ihr Passwort",
"remember": "Angemeldet bleiben",
"forgot": "Passwort vergessen?",
"submit": "Anmelden",
"no_account": "Noch keinen Account?",
"sign_up": "Jetzt registrieren",
"success_toast": "Login erfolgreich",
"unexpected_error": "Beim Login ist ein Fehler aufgetreten.",
"highlights": {
"moments": "Momente in Echtzeit teilen",
"moments_description": "Uploads landen sofort in der Event-Galerie ohne App-Download.",
"branding": "Branding & Slideshows, die begeistern",
"branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.",
"privacy": "Sicherer Zugang über Tokens",
"privacy_description": "Eventzugänge bleiben geschützt DSGVO-konform mit Join Tokens."
},
"hero_tagline": "Event-Tech mit Herz",
"hero_heading": "Willkommen zurück bei Fotospiel",
"hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.",
"hero_footer": {
"headline": "Noch kein Account?",
"subline": "Entdecke unsere Packages und erlebe Fotospiel live.",
"cta": "Packages entdecken"
}
"oauth_divider": "oder",
"google_cta": "Mit Google anmelden",
"google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.",
"no_account": "Noch keinen Zugang?",
"sign_up": "Jetzt registrieren"
},
"register": {
"title": "Registrieren",
"name": "Vollständiger Name",
"username": "Username",
"username_placeholder": "Wählen Sie einen Username",
"email": "E-Mail-Adresse",
"email_placeholder": "ihre@email.de",
"password": "Passwort",
"password_placeholder": "Mindestens 8 Zeichen",
"password_confirmation": "Passwort bestätigen",
"confirm_password": "Passwort bestätigen",
"confirm_password_placeholder": "Passwort wiederholen",
"first_name": "Vorname",
"first_name_placeholder": "Max",
"last_name": "Nachname",
"last_name_placeholder": "Mustermann",
"address": "Adresse",
"address_placeholder": "Straße Hausnummer, PLZ Ort",
"phone": "Telefonnummer",
"phone_placeholder": "+49 123 456789",
"privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.",
"privacy_policy": "Datenschutzerklärung",
"submit": "Registrieren",
"success_toast": "Registrierung erfolgreich",
"validation_failed": "Bitte prüfen Sie Ihre Eingaben.",
"unexpected_error": "Registrierung nicht möglich.",
"errors_title": "Fehler bei der Registrierung",
"errors": {
"username": "Username",
"email": "E-Mail",
"password": "Passwort",
"password_confirmation": "Passwort-Bestätigung",
"first_name": "Vorname",
"last_name": "Nachname",
"address": "Adresse",
"phone": "Telefon",
"privacy_consent": "Datenschutz-Zustimmung"
}
},
"common": {
"ui": {
"language_select": "Sprache auswählen"
}
},
"settings": {
"profile": {
"title": "Profil-Einstellungen",
"section_title": "Profilinformationen",
"description": "Aktualisieren Sie Ihren Namen und Ihre E-Mail-Adresse",
"email": "E-Mail-Adresse",
"email_placeholder": "E-Mail-Adresse",
"username": "Username",
"username_placeholder": "Username",
"language": "Sprache",
"email_unverified": "Ihre E-Mail-Adresse ist nicht verifiziert.",
"resend_verification": "Klicken Sie hier, um die Verifizierungs-E-Mail erneut zu senden.",
"verification_sent": "Ein neuer Verifizierungslink wurde an Ihre E-Mail-Adresse gesendet."
},
"password": {
"title": "Passwort-Einstellungen",
"section_title": "Passwort aktualisieren",
"description": "Stellen Sie sicher, dass Ihr Konto ein langes, zufälliges Passwort verwendet, um sicher zu bleiben",
"current": "Aktuelles Passwort",
"current_placeholder": "Aktuelles Passwort",
"new": "Neues Passwort",
"new_placeholder": "Neues Passwort",
"confirm": "Passwort bestätigen",
"confirm_placeholder": "Passwort bestätigen",
"save_button": "Passwort speichern"
}
},
"reset": {
"password": "Passwort",
"password_placeholder": "Passwort",
"confirm_password": "Passwort bestätigen",
"confirm_password_placeholder": "Passwort bestätigen",
"email": "E-Mail",
"email_placeholder": "email@beispiel.de",
"title": "Passwort zurücksetzen",
"description": "Bitte geben Sie Ihr neues Passwort unten ein",
"submit": "Passwort zurücksetzen"
},
"confirm": {
"password": "Passwort",
"password_placeholder": "Passwort",
"confirm": "Passwort bestätigen",
"title": "Passwort bestätigen",
"description": "Dies ist ein sicherer Bereich der Anwendung. Bitte bestätigen Sie Ihr Passwort, bevor Sie fortfahren.",
"submit": "Passwort bestätigen"
},
"forgot": {
"email": "E-Mail-Adresse",
"email_placeholder": "email@beispiel.de",
"submit": "Passwort-Reset-Link per E-Mail senden",
"back": "Oder zurück zur Anmeldung"
},
"delete_user": {
"password": "Passwort",
"password_placeholder": "Passwort",
"confirm_text": "Sobald Ihr Konto gelöscht ist, werden alle seine Ressourcen und Daten auch dauerhaft gelöscht. Bitte geben Sie Ihr Passwort ein, um zu bestätigen, dass Sie Ihr Konto dauerhaft löschen möchten."
"submit": "Registrieren"
},
"verification": {
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",

View File

@@ -1,168 +1,58 @@
{
"header": {
"home": "Home",
"how_it_works": "How it works",
"packages": "Packages",
"blog": "Blog",
"occasions": {
"wedding": "Weddings",
"birthday": "Birthdays",
"corporate": "Corporate Events",
"confirmation": "Confirmation/Coming of Age",
"label": "Occasions"
},
"contact": "Contact",
"login": "Login",
"register": "Register",
"cta": "Try now",
"utility": "Display & language",
"appearance": "Appearance",
"appearance_light": "Light",
"appearance_dark": "Dark",
"language": "Language"
},
"login_failed": "Invalid email or password.",
"login_success": "You are now logged in.",
"registration_failed": "Registration failed.",
"registration_success": "Registration successful proceed with purchase.",
"already_logged_in": "You are already logged in.",
"failed_credentials": "Invalid credentials.",
"login": {
"title": "Login",
"description": "Sign in to your Fotospiel account and keep your event experience flowing from upload to download.",
"identifier": "Email or Username",
"identifier_placeholder": "Enter your email or username",
"email": "Email",
"email_placeholder": "Enter your email",
"password": "Password",
"password_placeholder": "Enter your password",
"forgot": "Forgot Password?",
"remember": "Remember me",
"submit": "Login",
"no_account": "No account yet?",
"sign_up": "Create one now",
"success_toast": "Login successful",
"unexpected_error": "Unable to log in right now.",
"highlights": {
"moments": "Share moments in real time",
"moments_description": "Uploads appear instantly in the event gallery no app installation required.",
"branding": "Branding & slideshows that delight",
"branding_description": "Fine-tune slideshows, watermarks, and event tasks with a few clicks.",
"privacy": "Secure access via join tokens",
"privacy_description": "Keep events private and GDPR compliant with protected join tokens."
"failed_credentials": "Wrong credentials.",
"header": {
"login": "Login",
"register": "Register",
"home": "Home",
"packages": "Packages",
"blog": "Blog",
"occasions": {
"wedding": "Wedding",
"birthday": "Birthday",
"corporate": "Corporate Event"
},
"hero_tagline": "Event-tech with heart",
"hero_heading": "Welcome back to Fotospiel",
"hero_subheading": "Manage events, galleries, and guest lists in a lovingly crafted dashboard.",
"hero_footer": {
"headline": "Need an account?",
"subline": "Explore our packages and experience Fotospiel live.",
"cta": "Explore packages"
}
"contact": "Contact"
},
"login": {
"title": "Die Fotospiel.App",
"description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel.App",
"logo_alt": "Die Fotospiel.App logo",
"username_or_email": "Username or Email",
"email": "Email Address",
"email_placeholder": "your@email.com",
"password": "Password",
"password_placeholder": "Your password",
"remember": "Stay logged in",
"forgot": "Forgot password?",
"submit": "Login",
"oauth_divider": "or",
"google_cta": "Continue with Google",
"google_helper": "Use your Google account to access the event dashboard securely.",
"no_account": "Don't have access yet?",
"sign_up": "Create an account"
},
"register": {
"title": "Register",
"name": "Full Name",
"username": "Username",
"username_placeholder": "Choose a username",
"email": "Email Address",
"email_placeholder": "your@email.com",
"password": "Password",
"password_placeholder": "At least 8 characters",
"password_confirmation": "Confirm Password",
"confirm_password": "Confirm Password",
"confirm_password_placeholder": "Repeat password",
"password_confirmation": "Confirm password",
"first_name": "First Name",
"first_name_placeholder": "John",
"last_name": "Last Name",
"last_name_placeholder": "Doe",
"address": "Address",
"address_placeholder": "Street Number, ZIP City",
"phone": "Phone Number",
"phone_placeholder": "+1 123 456789",
"privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.",
"privacy_policy": "Privacy Policy",
"submit": "Register",
"success_toast": "Registration successful",
"validation_failed": "Please check your input.",
"unexpected_error": "Registration not possible.",
"errors_title": "Registration errors",
"errors": {
"username": "Username",
"email": "Email",
"password": "Password",
"password_confirmation": "Password Confirmation",
"first_name": "First Name",
"last_name": "Last Name",
"address": "Address",
"phone": "Phone",
"privacy_consent": "Privacy Consent"
}
},
"common": {
"ui": {
"language_select": "Select Language"
}
},
"settings": {
"profile": {
"title": "Profile settings",
"section_title": "Profile information",
"description": "Update your name and email address",
"email": "Email address",
"email_placeholder": "Email address",
"username": "Username",
"username_placeholder": "Username",
"language": "Language",
"email_unverified": "Your email address is unverified.",
"resend_verification": "Click here to resend the verification email.",
"verification_sent": "A new verification link has been sent to your email address."
},
"password": {
"title": "Password settings",
"section_title": "Update password",
"description": "Ensure your account is using a long, random password to stay secure",
"current": "Current password",
"current_placeholder": "Current password",
"new": "New password",
"new_placeholder": "New password",
"confirm": "Confirm password",
"confirm_placeholder": "Confirm password",
"save_button": "Save password"
}
},
"reset": {
"password": "Password",
"password_placeholder": "Password",
"confirm_password": "Confirm password",
"confirm_password_placeholder": "Confirm password",
"email": "Email",
"email_placeholder": "email@example.com",
"title": "Reset password",
"description": "Please enter your new password below",
"submit": "Reset password"
},
"confirm": {
"password": "Password",
"password_placeholder": "Password",
"confirm": "Confirm password",
"title": "Confirm your password",
"description": "This is a secure area of the application. Please confirm your password before continuing.",
"submit": "Confirm password"
},
"forgot": {
"email": "Email address",
"email_placeholder": "email@example.com",
"submit": "Email password reset link",
"back": "Or, return to log in"
},
"delete_user": {
"password": "Password",
"password_placeholder": "Password",
"confirm_text": "Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account."
"submit": "Register"
},
"verification": {
"notice": "Please confirm your email address.",
"notice": "Please verify your email address.",
"resend": "Resend email"
}
}

View File

@@ -5,6 +5,24 @@ import i18n from './i18n';
type JsonValue = Record<string, unknown>;
export type TenantAccountProfile = {
id: number;
name: string;
email: string;
preferred_locale: string | null;
email_verified: boolean;
email_verified_at: string | null;
};
export type UpdateTenantProfilePayload = {
name: string;
email: string;
preferred_locale?: string | null;
current_password?: string;
password?: string;
password_confirmation?: string;
};
export type EventQrInviteLayout = {
id: string;
name: string;
@@ -353,10 +371,16 @@ async function jsonOrThrow<T>(response: Response, message: string, options: Json
const errorCode = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.code === 'string'
? errorPayload.code
: undefined;
const errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
let errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
? errorPayload.meta as Record<string, unknown>
: undefined;
if (!errorMeta && body && typeof body === 'object' && 'errors' in body && typeof body.errors === 'object') {
errorMeta = {
errors: body.errors as Record<string, unknown>,
};
}
if (!options.suppressToast) {
emitApiErrorEvent({ message: errorMessage, status, code: errorCode, meta: errorMeta });
}
@@ -1102,6 +1126,49 @@ export async function getNotificationPreferences(): Promise<NotificationPreferen
};
}
type ProfileResponse = {
data?: TenantAccountProfile;
message?: string;
};
export async function fetchTenantProfile(): Promise<TenantAccountProfile> {
const response = await authorizedFetch('/api/v1/tenant/profile');
const payload = await jsonOrThrow<ProfileResponse>(
response,
i18n.t('settings.profile.errors.load', 'Profil konnte nicht geladen werden.'),
{ suppressToast: true }
);
if (!payload.data) {
throw new Error('Profilantwort war leer.');
}
return payload.data;
}
export async function updateTenantProfile(payload: UpdateTenantProfilePayload): Promise<TenantAccountProfile> {
const response = await authorizedFetch('/api/v1/tenant/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
const json = await jsonOrThrow<ProfileResponse>(
response,
i18n.t('settings.profile.errors.update', 'Profil konnte nicht aktualisiert werden.'),
{ suppressToast: true }
);
if (!json.data) {
throw new Error('Profilantwort war leer.');
}
return json.data;
}
export async function updateNotificationPreferences(
preferences: NotificationPreferences
): Promise<NotificationPreferenceResponse> {

View File

@@ -8,7 +8,7 @@ import {
registerAuthFailureHandler,
startOAuthFlow,
} from './tokens';
import { ADMIN_LOGIN_PATH } from '../constants';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
@@ -86,11 +86,27 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, [handleAuthFailure, refreshProfile]);
const login = React.useCallback((redirectPath?: string) => {
const target = redirectPath ?? window.location.pathname + window.location.search;
const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`;
startOAuthFlow(target);
}, []);
const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => {
const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => {
try {
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
await fetch('/logout', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
credentials: 'same-origin',
});
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to notify backend about logout', error);
}
} finally {
clearTokens();
clearOAuthSession();
setUser(null);
@@ -98,6 +114,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (redirect) {
window.location.href = redirect;
}
}
}, []);
const completeLogin = React.useCallback(

View File

@@ -4,10 +4,13 @@ export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`;
export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH;
export const ADMIN_HOME_PATH = adminPath('/dashboard');
export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = adminPath('/events');
export const ADMIN_LOGIN_PATH = adminPath('/login');
export const ADMIN_LOGIN_START_PATH = adminPath('/start');
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/events');
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
export const ADMIN_PROFILE_PATH = adminPath('/settings/profile');
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;

View File

@@ -10,6 +10,8 @@ import deOnboarding from './locales/de/onboarding.json';
import enOnboarding from './locales/en/onboarding.json';
import deManagement from './locales/de/management.json';
import enManagement from './locales/en/management.json';
import deSettings from './locales/de/settings.json';
import enSettings from './locales/en/settings.json';
import deAuth from './locales/de/auth.json';
import enAuth from './locales/en/auth.json';
@@ -21,6 +23,7 @@ const resources = {
dashboard: deDashboard,
onboarding: deOnboarding,
management: deManagement,
settings: deSettings,
auth: deAuth,
},
en: {
@@ -28,6 +31,7 @@ const resources = {
dashboard: enDashboard,
onboarding: enOnboarding,
management: enManagement,
settings: enSettings,
auth: enAuth,
},
} as const;

View File

@@ -13,10 +13,25 @@
"lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.",
"panel_title": "Melde dich an",
"panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit OAuth 2.1 und klaren Rollenrechten.",
"actions_title": "Wähle deine Anmeldemethode",
"actions_copy": "Greife sicher per OAuth oder mit deinem Google-Konto auf das Tenant-Dashboard zu.",
"cta": "Mit Fotospiel-Login fortfahren",
"google_cta": "Mit Google anmelden",
"open_account_login": "Konto-Login öffnen",
"loading": "Bitte warten …",
"oauth_error_title": "Login aktuell nicht möglich",
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}",
"oauth_errors": {
"login_required": "Bitte melde dich zuerst in deinem Fotospiel-Konto an.",
"invalid_request": "Die Login-Anfrage war ungültig. Bitte versuche es erneut.",
"invalid_client": "Die verknüpfte Tenant-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.",
"invalid_redirect": "Die angegebene Weiterleitungsadresse ist für diese App nicht hinterlegt.",
"invalid_scope": "Die App fordert Berechtigungen an, die nicht freigegeben sind.",
"tenant_mismatch": "Du hast keinen Zugriff auf den Tenant, der diese Anmeldung angefordert hat.",
"google_failed": "Die Anmeldung mit Google war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.",
"google_no_match": "Wir konnten dieses Google-Konto keinem Tenant-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an."
},
"return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.",
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
"appearance_label": "Darstellung"
}

View File

@@ -0,0 +1,51 @@
{
"profile": {
"title": "Profil",
"subtitle": "Verwalte deine Kontodaten und Zugangsdaten.",
"sections": {
"account": {
"heading": "Account-Informationen",
"description": "Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an."
},
"password": {
"heading": "Passwort ändern",
"description": "Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.",
"hint": "Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit."
}
},
"fields": {
"name": "Anzeigename",
"email": "E-Mail-Adresse",
"locale": "Bevorzugte Sprache",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"passwordConfirmation": "Passwort bestätigen"
},
"placeholders": {
"name": "z. B. Hochzeitsplanung Schmidt",
"locale": "Systemsprache verwenden"
},
"locale": {
"auto": "Automatisch"
},
"status": {
"emailVerified": "E-Mail bestätigt",
"emailNotVerified": "Bestätigung erforderlich",
"verifiedHint": "Bestätigt am {{date}}.",
"unverifiedHint": "Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung."
},
"actions": {
"save": "Speichern",
"updatePassword": "Passwort aktualisieren",
"openProfile": "Profil bearbeiten"
},
"toasts": {
"updated": "Profil wurde aktualisiert.",
"passwordChanged": "Passwort wurde aktualisiert."
},
"errors": {
"load": "Profil konnte nicht geladen werden.",
"update": "Profil konnte nicht aktualisiert werden."
}
}
}

View File

@@ -13,10 +13,25 @@
"lead": "Use our secure OAuth login and land directly in the event dashboard.",
"panel_title": "Sign in",
"panel_copy": "Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.",
"actions_title": "Choose your sign-in method",
"actions_copy": "Access the tenant dashboard securely with OAuth or your Google account.",
"cta": "Continue with Fotospiel login",
"google_cta": "Continue with Google",
"open_account_login": "Open account login",
"loading": "Signing you in …",
"oauth_error_title": "Login not possible right now",
"oauth_error": "Sign-in failed: {{message}}",
"oauth_errors": {
"login_required": "Please sign in to your Fotospiel account before continuing.",
"invalid_request": "The login request was invalid. Please try again.",
"invalid_client": "We couldnt find the linked tenant app. Please contact support if this persists.",
"invalid_redirect": "The redirect address is not registered for this app.",
"invalid_scope": "The app asked for permissions it cannot receive.",
"tenant_mismatch": "You dont have access to the tenant that requested this login.",
"google_failed": "Google sign-in was not successful. Please try again or use another method.",
"google_no_match": "We couldnt link this Google account to a tenant admin. Please sign in with Fotospiel credentials."
},
"return_hint": "After signing in youll be brought back automatically.",
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
"appearance_label": "Appearance"
}

View File

@@ -0,0 +1,51 @@
{
"profile": {
"title": "Profile",
"subtitle": "Manage your account details and credentials.",
"sections": {
"account": {
"heading": "Account information",
"description": "Update your name, email address, and interface language."
},
"password": {
"heading": "Change password",
"description": "Choose a strong password to protect admin access.",
"hint": "Use at least 8 characters and mix letters and numbers for higher security."
}
},
"fields": {
"name": "Display name",
"email": "Email address",
"locale": "Preferred language",
"currentPassword": "Current password",
"newPassword": "New password",
"passwordConfirmation": "Confirm password"
},
"placeholders": {
"name": "e.g. Event Planning Smith",
"locale": "Use system language"
},
"locale": {
"auto": "Automatic"
},
"status": {
"emailVerified": "Email verified",
"emailNotVerified": "Verification required",
"verifiedHint": "Verified on {{date}}.",
"unverifiedHint": "We'll send another verification email when you change the address."
},
"actions": {
"save": "Save",
"updatePassword": "Update password",
"openProfile": "Edit profile"
},
"toasts": {
"updated": "Profile updated successfully.",
"passwordChanged": "Password updated."
},
"errors": {
"load": "Unable to load your profile.",
"update": "Could not update your profile."
}
}
}

View File

@@ -0,0 +1,172 @@
import { ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH } from '../constants';
const LAST_DESTINATION_KEY = 'tenant.oauth.lastDestination';
function ensureLeadingSlash(target: string): string {
if (!target) {
return '/';
}
if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(target)) {
return target;
}
return target.startsWith('/') ? target : `/${target}`;
}
function base64UrlEncode(value: string): string {
const encoder = new TextEncoder();
const bytes = encoder.encode(value);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
}
function base64UrlDecode(value: string): string | null {
try {
const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=');
const normalized = padded.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(normalized);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
const decoder = new TextDecoder();
return decoder.decode(bytes);
} catch (error) {
console.warn('[Auth] Failed to decode return_to parameter', error);
return null;
}
}
export function encodeReturnTo(value: string): string {
const trimmed = value.trim();
if (trimmed === '') {
return '';
}
return base64UrlEncode(trimmed);
}
export function decodeReturnTo(value: string | null): string | null {
if (!value) {
return null;
}
return base64UrlDecode(value);
}
export interface ReturnTargetResolution {
finalTarget: string;
encodedFinal: string;
}
export function resolveReturnTarget(raw: string | null, fallback: string): ReturnTargetResolution {
const normalizedFallback = ensureLeadingSlash(fallback);
if (!raw) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
}
const decodedPrimary = decodeReturnTo(raw);
if (!decodedPrimary) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
}
const normalizedPrimary = decodedPrimary.trim();
const wrapperPaths = [ADMIN_LOGIN_START_PATH, ADMIN_LOGIN_PATH];
for (const wrapper of wrapperPaths) {
if (normalizedPrimary.startsWith(wrapper)) {
try {
const url = new URL(normalizedPrimary, window.location.origin);
const innerRaw = url.searchParams.get('return_to');
if (!innerRaw) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
}
return resolveReturnTarget(innerRaw, normalizedFallback);
} catch (error) {
console.warn('[Auth] Failed to parse return_to chain', error);
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
}
}
}
const finalTarget = ensureLeadingSlash(normalizedPrimary);
const encodedFinal = encodeReturnTo(finalTarget);
return { finalTarget, encodedFinal };
}
export function buildAdminOAuthStartPath(targetPath: string, encodedTarget?: string): string {
const sanitizedTarget = ensureLeadingSlash(targetPath);
const encoded = encodedTarget ?? encodeReturnTo(sanitizedTarget);
const url = new URL(ADMIN_LOGIN_START_PATH, window.location.origin);
url.searchParams.set('return_to', encoded);
return `${url.pathname}${url.search}`;
}
function resolveLocale(): string {
const raw = document.documentElement.lang || 'de';
const normalized = raw.toLowerCase();
if (normalized.includes('-')) {
return normalized.split('-')[0] || 'de';
}
return normalized || 'de';
}
export function buildMarketingLoginUrl(returnPath: string): string {
const sanitizedPath = ensureLeadingSlash(returnPath);
const encoded = encodeReturnTo(sanitizedPath);
const locale = resolveLocale();
const loginPath = `/${locale}/login`;
const url = new URL(loginPath, window.location.origin);
url.searchParams.set('return_to', encoded);
return url.toString();
}
export function storeLastDestination(path: string): void {
if (typeof window === 'undefined') {
return;
}
const sanitized = ensureLeadingSlash(path.trim());
try {
window.sessionStorage.setItem(LAST_DESTINATION_KEY, sanitized);
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to store last destination', error);
}
}
}
export function consumeLastDestination(): string | null {
if (typeof window === 'undefined') {
return null;
}
try {
const value = window.sessionStorage.getItem(LAST_DESTINATION_KEY);
if (value) {
window.sessionStorage.removeItem(LAST_DESTINATION_KEY);
return ensureLeadingSlash(value);
}
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to read last destination', error);
}
}
return null;
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/context';
import { isAuthError } from '../auth/tokens';
import { ADMIN_HOME_PATH } from '../constants';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
export default function AuthCallbackPage() {
const { completeLogin } = useAuth();
@@ -19,7 +19,7 @@ export default function AuthCallbackPage() {
const params = new URLSearchParams(window.location.search);
completeLogin(params)
.then((redirectTo) => {
navigate(redirectTo ?? ADMIN_HOME_PATH, { replace: true });
navigate(redirectTo ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH, { replace: true });
})
.catch((err) => {
console.error('[Auth] Callback processing failed', err);
@@ -40,4 +40,3 @@ export default function AuthCallbackPage() {
</div>
);
}

View File

@@ -1,164 +1,162 @@
import React from 'react';
import { Location, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles, ShieldCheck, Images, ArrowRight, Loader2 } from 'lucide-react';
import { ArrowRight, Loader2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import AppLogoIcon from '@/components/app-logo-icon';
import { useAuth } from '../auth/context';
import { ADMIN_HOME_PATH } from '../constants';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, encodeReturnTo, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
interface LocationState {
from?: Location;
}
const featureIcons = [Sparkles, ShieldCheck, Images];
export default function LoginPage(): JSX.Element {
const { status, login } = useAuth();
const { t } = useTranslation('auth');
const location = useLocation();
const navigate = useNavigate();
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const oauthError = searchParams.get('error');
const oauthErrorDescription = searchParams.get('error_description');
const rawReturnTo = searchParams.get('return_to');
const { finalTarget, encodedFinal } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
[rawReturnTo]
);
const resolvedErrorMessage = React.useMemo(() => {
if (!oauthError) {
return null;
}
const errorMap: Record<string, string> = {
login_required: t('login.oauth_errors.login_required'),
invalid_request: t('login.oauth_errors.invalid_request'),
invalid_client: t('login.oauth_errors.invalid_client'),
invalid_redirect: t('login.oauth_errors.invalid_redirect'),
invalid_scope: t('login.oauth_errors.invalid_scope'),
tenant_mismatch: t('login.oauth_errors.tenant_mismatch'),
google_failed: t('login.oauth_errors.google_failed'),
google_no_match: t('login.oauth_errors.google_no_match'),
};
return errorMap[oauthError] ?? oauthErrorDescription ?? oauthError;
}, [oauthError, oauthErrorDescription, t]);
React.useEffect(() => {
if (status === 'authenticated') {
navigate(ADMIN_HOME_PATH, { replace: true });
navigate(finalTarget, { replace: true });
}
}, [status, navigate]);
}, [finalTarget, navigate, status]);
const redirectTarget = React.useMemo(() => {
if (finalTarget) {
return finalTarget;
}
const state = location.state as LocationState | null;
if (state?.from) {
const from = state.from;
const search = from.search ?? '';
const hash = from.hash ?? '';
return `${from.pathname}${search}${hash}`;
const path = `${from.pathname}${search}${hash}`;
return path.startsWith('/') ? path : `/${path}`;
}
return ADMIN_HOME_PATH;
}, [location.state]);
const featureList = React.useMemo(() => {
const raw = t('login.features', { returnObjects: true }) as unknown;
if (!Array.isArray(raw)) {
return [] as Array<{ text: string; Icon: typeof Sparkles }>;
}
return (raw as string[]).map((entry, index) => ({
text: entry,
Icon: featureIcons[index % featureIcons.length],
}));
}, [t]);
const heroTagline = t('login.hero_tagline', 'Stay in control, stay relaxed');
const heroTitle = t('login.hero_title', 'Your cockpit for every Fotospiel event');
const heroSubtitle = t('login.hero_subtitle', 'Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.');
const panelTitle = t('login.panel_title', t('login.title', 'Event Admin'));
const leadCopy = t('login.lead', 'Use our secure OAuth login and land directly in the event dashboard.');
const panelCopy = t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.');
const supportCopy = t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.");
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
}, [finalTarget, location.state]);
const shouldOpenAccountLogin = oauthError === 'login_required';
const isLoading = status === 'loading';
const hasAutoStartedRef = React.useRef(false);
const oauthStartPath = React.useMemo(
() => buildAdminOAuthStartPath(redirectTarget, encodedFinal),
[encodedFinal, redirectTarget]
);
const marketingLoginUrl = React.useMemo(() => buildMarketingLoginUrl(oauthStartPath), [oauthStartPath]);
const hasRedirectedRef = React.useRef(false);
React.useEffect(() => {
if (!shouldOpenAccountLogin || hasRedirectedRef.current) {
return;
}
hasRedirectedRef.current = true;
window.location.replace(marketingLoginUrl);
}, [marketingLoginUrl, shouldOpenAccountLogin]);
React.useEffect(() => {
if (status !== 'unauthenticated' || oauthError || hasAutoStartedRef.current) {
return;
}
hasAutoStartedRef.current = true;
storeLastDestination(redirectTarget);
login(redirectTarget);
}, [login, oauthError, redirectTarget, status]);
const googleHref = React.useMemo(() => {
const target = new URL('/event-admin/auth/google', window.location.origin);
target.searchParams.set('return_to', encodeReturnTo(oauthStartPath));
return target.toString();
}, [oauthStartPath]);
return (
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
<div className="relative min-h-svh overflow-hidden bg-slate-950 px-4 py-16 text-white">
<div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.35),_transparent_55%)] blur-3xl"
/>
<div className="absolute inset-0 bg-gradient-to-br from-gray-950 via-gray-950/70 to-[#1a0f1f]" aria-hidden />
<div className="relative z-10 flex min-h-svh flex-col">
<header className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 pt-10 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/10 backdrop-blur">
<div className="relative z-10 mx-auto flex w-full max-w-md flex-col items-center gap-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/20">
<AppLogoIcon className="h-7 w-7 text-white" />
</span>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge', 'Fotospiel Event Admin')}</p>
<p className="text-lg font-semibold">Fotospiel</p>
</div>
</div>
<AppearanceToggleDropdown />
</header>
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-4 pb-16 pt-12 sm:px-6 lg:px-8">
<div className="mb-10 space-y-5 text-center md:hidden">
<span className="inline-flex items-center gap-2 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
<Sparkles className="h-3.5 w-3.5" aria-hidden />
{heroTagline}
</span>
<h1 className="text-3xl font-semibold leading-tight sm:text-4xl">{heroTitle}</h1>
<p className="mx-auto max-w-xl text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
</div>
<div className="grid flex-1 gap-10 md:grid-cols-[1.08fr_1fr]" data-testid="tenant-login-layout">
<section className="relative hidden h-full flex-col justify-between gap-10 overflow-hidden rounded-3xl border border-white/15 bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] p-10 md:flex">
<div aria-hidden className="pointer-events-none absolute inset-0 opacity-40">
<div className="absolute -inset-16 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.5),_transparent_55%),radial-gradient(circle_at_bottom_left,_rgba(236,72,153,0.35),_transparent_60%)]" />
</div>
<div className="relative z-10 flex flex-col gap-6">
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.45em] text-white/70">
<Sparkles className="h-4 w-4" aria-hidden />
<span className="font-sans-marketing">{heroTagline}</span>
</div>
<div className="space-y-4">
<h2 className="font-display text-3xl leading-tight sm:text-4xl">{heroTitle}</h2>
<p className="text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
</div>
{featureList.length ? (
<ul className="space-y-4">
{featureList.map(({ text, Icon }, index) => (
<li key={`login-feature-desktop-${index}`} className="flex items-start gap-3">
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15 backdrop-blur">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="space-y-1">
<p className="text-sm font-semibold tracking-tight sm:text-base">{text}</p>
</span>
</li>
))}
</ul>
) : null}
</div>
<p className="relative z-10 flex items-center gap-2 text-xs font-medium text-white/75">
<ArrowRight className="h-4 w-4" aria-hidden />
{leadCopy}
<h1 className="text-2xl font-semibold">{t('login.panel_title', t('login.title', 'Event Admin'))}</h1>
<p className="max-w-sm text-sm text-white/70">
{t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.')}
</p>
</section>
<section className="relative">
<div className="absolute inset-0 -translate-y-4 translate-x-4 scale-95 rounded-3xl bg-white/20 opacity-45 blur-2xl" aria-hidden />
<div className="relative flex h-full flex-col justify-between gap-6 overflow-hidden rounded-3xl border border-white/15 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
<div className="space-y-3">
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200/50 bg-rose-50/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-500/80 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200/80">
{t('login.badge', 'Fotospiel Event Admin')}
</span>
<div className="space-y-1">
<h2 className="text-2xl font-semibold">{panelTitle}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{panelCopy}</p>
</div>
</div>
{oauthError ? (
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
{resolvedErrorMessage ? (
<Alert className="w-full border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
<AlertDescription>{resolvedErrorMessage}</AlertDescription>
</Alert>
) : null}
<div className="w-full space-y-4 rounded-3xl border border-white/10 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
<div className="space-y-2 text-left">
<h2 className="text-xl font-semibold">{t('login.actions_title', 'Choose your sign-in method')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-300">
{t('login.actions_copy', 'Access the tenant dashboard securely with OAuth or your Google account.')}
</p>
</div>
<div className="space-y-3">
<Button
size="lg"
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] focus-visible:ring-4 focus-visible:ring-rose-400/40"
disabled={isLoading}
onClick={() => login(redirectTarget)}
onClick={() => {
if (shouldOpenAccountLogin) {
window.location.href = marketingLoginUrl;
return;
}
storeLastDestination(redirectTarget);
login(redirectTarget);
}}
>
{isLoading ? (
<>
@@ -167,38 +165,59 @@ export default function LoginPage(): JSX.Element {
</>
) : (
<>
{t('login.cta', 'Continue with Fotospiel login')}
{shouldOpenAccountLogin
? t('login.open_account_login')
: t('login.cta', 'Continue with Fotospiel login')}
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
</>
)}
</Button>
<div className="space-y-2 text-xs leading-relaxed text-slate-500 dark:text-slate-300">
<p>{leadCopy}</p>
<p>{supportCopy}</p>
</div>
</div>
</section>
<Button
variant="outline"
size="lg"
className="flex w-full items-center justify-center gap-3 rounded-full border-slate-200 bg-white text-slate-900 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-50 dark:hover:bg-slate-800"
onClick={() => {
window.location.href = googleHref;
}}
>
<GoogleIcon className="h-5 w-5" />
{t('login.google_cta', 'Continue with Google')}
</Button>
</div>
{featureList.length ? (
<div className="mt-10 grid gap-4 md:hidden">
{featureList.map(({ text, Icon }, index) => (
<div
key={`login-feature-mobile-${index}`}
className="flex items-start gap-3 rounded-2xl border border-white/15 bg-white/10 p-4 text-sm text-white/85 shadow-lg shadow-black/15 backdrop-blur"
>
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15">
<Icon className="h-4 w-4" aria-hidden />
</span>
<p>{text}</p>
</div>
))}
<p className="text-xs text-slate-500 dark:text-slate-300">
{t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.")}
</p>
</div>
{redirectTarget !== ADMIN_DEFAULT_AFTER_LOGIN_PATH ? (
<p className="text-center text-xs text-white/50">{t('login.return_hint')}</p>
) : null}
</main>
</div>
</div>
);
}
function GoogleIcon({ className }: { className?: string }): JSX.Element {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
fill="#4285F4"
d="M23.52 12.272c0-.851-.076-1.67-.217-2.455H12v4.639h6.44a5.51 5.51 0 0 1-2.393 3.622v3.01h3.88c2.271-2.093 3.593-5.18 3.593-8.816Z"
/>
<path
fill="#34A853"
d="M12 24c3.24 0 5.957-1.073 7.943-2.912l-3.88-3.01c-1.073.72-2.446 1.147-4.063 1.147-3.124 0-5.773-2.111-6.717-4.954H1.245v3.11C3.221 21.64 7.272 24 12 24Z"
/>
<path
fill="#FBBC05"
d="M5.283 14.27a7.2 7.2 0 0 1 0-4.54V6.62H1.245a11.996 11.996 0 0 0 0 10.76l4.038-3.11Z"
/>
<path
fill="#EA4335"
d="M12 4.75c1.761 0 3.344.606 4.595 1.794l3.447-3.447C17.957 1.012 15.24 0 12 0 7.272 0 3.221 2.36 1.245 6.62l4.038 3.11C6.227 6.861 8.876 4.75 12 4.75Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
export default function LoginStartPage(): JSX.Element {
const { status, login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const rawReturnTo = searchParams.get('return_to');
const { finalTarget, encodedFinal } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
[rawReturnTo]
);
const [hasStarted, setHasStarted] = React.useState(false);
React.useEffect(() => {
if (status === 'authenticated') {
navigate(finalTarget, { replace: true });
return;
}
if (hasStarted || status === 'loading') {
return;
}
setHasStarted(true);
storeLastDestination(finalTarget);
login(finalTarget);
}, [finalTarget, hasStarted, login, navigate, status]);
React.useEffect(() => {
if (status !== 'unauthenticated' || !hasStarted) {
return;
}
const oauthStartPath = buildAdminOAuthStartPath(finalTarget, encodedFinal);
const marketingLoginUrl = buildMarketingLoginUrl(oauthStartPath);
window.location.replace(marketingLoginUrl);
}, [encodedFinal, finalTarget, hasStarted, status]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70">
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden />
<p className="text-sm font-medium">Melde dich an </p>
<p className="max-w-xs text-xs text-white/50">Wir verbinden dich automatisch mit deinem Event-Dashboard.</p>
</div>
);
}

View File

@@ -0,0 +1,421 @@
import React from 'react';
import { Loader2, ShieldCheck, ShieldX, Mail, User as UserIcon, Globe, Lock } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { AdminLayout } from '../components/AdminLayout';
import { useAuth } from '../auth/context';
import {
fetchTenantProfile,
updateTenantProfile,
type TenantAccountProfile,
type UpdateTenantProfilePayload,
} from '../api';
import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
type FieldErrors = Record<string, string>;
function extractFieldErrors(error: unknown): FieldErrors {
if (isApiError(error) && error.meta && typeof error.meta.errors === 'object') {
const entries = error.meta.errors as Record<string, unknown>;
const mapped: FieldErrors = {};
Object.entries(entries).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') {
mapped[key] = value[0];
}
});
return mapped;
}
return {};
}
const DEFAULT_LOCALES = ['de', 'en'];
const AUTO_LOCALE_OPTION = '__auto__';
export default function ProfilePage(): JSX.Element {
const { t } = useTranslation(['settings', 'common']);
const { refreshProfile } = useAuth();
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
const [loading, setLoading] = React.useState(true);
const [infoForm, setInfoForm] = React.useState({
name: '',
email: '',
preferred_locale: '',
});
const [passwordForm, setPasswordForm] = React.useState({
current_password: '',
password: '',
password_confirmation: '',
});
const [infoErrors, setInfoErrors] = React.useState<FieldErrors>({});
const [passwordErrors, setPasswordErrors] = React.useState<FieldErrors>({});
const [savingInfo, setSavingInfo] = React.useState(false);
const [savingPassword, setSavingPassword] = React.useState(false);
const availableLocales = React.useMemo(() => {
const candidates = new Set(DEFAULT_LOCALES);
if (typeof document !== 'undefined') {
const lang = document.documentElement.lang;
if (lang) {
const short = lang.toLowerCase().split('-')[0];
candidates.add(short);
}
}
if (profile?.preferred_locale) {
candidates.add(profile.preferred_locale.toLowerCase());
}
return Array.from(candidates).sort();
}, [profile?.preferred_locale]);
const selectedLocale = infoForm.preferred_locale && infoForm.preferred_locale !== '' ? infoForm.preferred_locale : AUTO_LOCALE_OPTION;
React.useEffect(() => {
let cancelled = false;
async function loadProfile(): Promise<void> {
setLoading(true);
try {
const data = await fetchTenantProfile();
if (cancelled) {
return;
}
setProfile(data);
setInfoForm({
name: data.name ?? '',
email: data.email ?? '',
preferred_locale: data.preferred_locale ?? '',
});
} catch (error) {
if (!cancelled) {
toast.error(getApiErrorMessage(error, t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadProfile();
return () => {
cancelled = true;
};
}, [t]);
const handleInfoSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setInfoErrors({});
setSavingInfo(true);
const payload: UpdateTenantProfilePayload = {
name: infoForm.name,
email: infoForm.email,
preferred_locale: infoForm.preferred_locale || null,
};
try {
const updated = await updateTenantProfile(payload);
setProfile(updated);
toast.success(t('settings:profile.toasts.updated', 'Profil wurde aktualisiert.'));
setInfoForm({
name: updated.name ?? '',
email: updated.email ?? '',
preferred_locale: updated.preferred_locale ?? '',
});
setPasswordForm((prev) => ({ ...prev, current_password: '' }));
await refreshProfile();
} catch (error) {
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
toast.error(message);
const fieldErrors = extractFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
setInfoErrors(fieldErrors);
}
} finally {
setSavingInfo(false);
}
},
[infoForm.email, infoForm.name, infoForm.preferred_locale, refreshProfile, t]
);
const handlePasswordSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPasswordErrors({});
setSavingPassword(true);
const payload: UpdateTenantProfilePayload = {
name: infoForm.name,
email: infoForm.email,
preferred_locale: infoForm.preferred_locale || null,
current_password: passwordForm.current_password || undefined,
password: passwordForm.password || undefined,
password_confirmation: passwordForm.password_confirmation || undefined,
};
try {
const updated = await updateTenantProfile(payload);
setProfile(updated);
toast.success(t('settings:profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
await refreshProfile();
} catch (error) {
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
toast.error(message);
const fieldErrors = extractFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
setPasswordErrors(fieldErrors);
}
} finally {
setSavingPassword(false);
}
},
[infoForm.email, infoForm.name, infoForm.preferred_locale, passwordForm, refreshProfile, t]
);
if (loading) {
return (
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
{t('common:loading', 'Wird geladen …')}
</div>
</div>
</AdminLayout>
);
}
if (!profile) {
return (
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
<div className="rounded-3xl border border-rose-200/60 bg-rose-50/70 p-8 text-center text-sm text-rose-600">
{t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')}
</div>
</AdminLayout>
);
}
return (
<AdminLayout
title={t('settings:profile.title', 'Profil')}
subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}
>
<Card className="border-0 bg-white/90 shadow-xl shadow-rose-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<UserIcon className="h-5 w-5 text-rose-500" />
{t('settings:profile.sections.account.heading', 'Account-Informationen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('settings:profile.sections.account.description', 'Passe Name, E-Mail und Sprache deiner Admin-Oberfläche an.')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInfoSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-name" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<UserIcon className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.name', 'Anzeigename')}
</Label>
<Input
id="profile-name"
value={infoForm.name}
onChange={(event) => setInfoForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('settings:profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
/>
{infoErrors.name && <p className="text-sm text-rose-500">{infoErrors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-email" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Mail className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.email', 'E-Mail-Adresse')}
</Label>
<Input
id="profile-email"
type="email"
value={infoForm.email}
onChange={(event) => setInfoForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="admin@example.com"
/>
{infoErrors.email && <p className="text-sm text-rose-500">{infoErrors.email}</p>}
</div>
</div>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-locale" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Globe className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.locale', 'Bevorzugte Sprache')}
</Label>
<Select
value={selectedLocale}
onValueChange={(value) =>
setInfoForm((prev) => ({ ...prev, preferred_locale: value === AUTO_LOCALE_OPTION ? '' : value }))
}
>
<SelectTrigger id="profile-locale" aria-label={t('settings:profile.fields.locale', 'Bevorzugte Sprache')}>
<SelectValue placeholder={t('settings:profile.placeholders.locale', 'Systemsprache verwenden')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUTO_LOCALE_OPTION}>{t('settings:profile.locale.auto', 'Automatisch')}</SelectItem>
{availableLocales.map((locale) => (
<SelectItem key={locale} value={locale} className="capitalize">
{locale}
</SelectItem>
))}
</SelectContent>
</Select>
{infoErrors.preferred_locale && <p className="text-sm text-rose-500">{infoErrors.preferred_locale}</p>}
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-semibold text-slate-800">
{profile.email_verified ? (
<>
<ShieldCheck className="h-4 w-4 text-emerald-500" />
{t('settings:profile.status.emailVerified', 'E-Mail bestätigt')}
</>
) : (
<>
<ShieldX className="h-4 w-4 text-rose-500" />
{t('settings:profile.status.emailNotVerified', 'Bestätigung erforderlich')}
</>
)}
</Label>
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-3 text-sm text-slate-600">
{profile.email_verified
? t('settings:profile.status.verifiedHint', 'Bestätigt am {{date}}.', {
date: profile.email_verified_at ? new Date(profile.email_verified_at).toLocaleString() : '',
})
: t('settings:profile.status.unverifiedHint', 'Wir senden dir eine neue Bestätigung, sobald du die E-Mail änderst.')}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={savingInfo} className="flex items-center gap-2">
{savingInfo && <Loader2 className="h-4 w-4 animate-spin" />}
{t('settings:profile.actions.save', 'Speichern')}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
if (!profile) {
return;
}
setInfoForm({
name: profile.name ?? '',
email: profile.email ?? '',
preferred_locale: profile.preferred_locale ?? '',
});
setInfoErrors({});
}}
>
{t('common:actions.reset', 'Zurücksetzen')}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Lock className="h-5 w-5 text-indigo-500" />
{t('settings:profile.sections.password.heading', 'Passwort ändern')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('settings:profile.sections.password.description', 'Wähle ein sicheres Passwort, um dein Admin-Konto zu schützen.')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handlePasswordSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="profile-current-password" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.currentPassword', 'Aktuelles Passwort')}
</Label>
<Input
id="profile-current-password"
type="password"
autoComplete="current-password"
value={passwordForm.current_password}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, current_password: event.target.value }))}
/>
{passwordErrors.current_password && <p className="text-sm text-rose-500">{passwordErrors.current_password}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-new-password" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.newPassword', 'Neues Passwort')}
</Label>
<Input
id="profile-new-password"
type="password"
autoComplete="new-password"
value={passwordForm.password}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password: event.target.value }))}
/>
{passwordErrors.password && <p className="text-sm text-rose-500">{passwordErrors.password}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-password-confirmation" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.passwordConfirmation', 'Passwort bestätigen')}
</Label>
<Input
id="profile-password-confirmation"
type="password"
autoComplete="new-password"
value={passwordForm.password_confirmation}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password_confirmation: event.target.value }))}
/>
{passwordErrors.password_confirmation && <p className="text-sm text-rose-500">{passwordErrors.password_confirmation}</p>}
</div>
</div>
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-4 text-xs text-slate-600">
{t('settings:profile.sections.password.hint', 'Dein Passwort muss mindestens 8 Zeichen lang sein und eine Mischung aus Buchstaben und Zahlen enthalten.')}
</div>
<div className="flex flex-wrap gap-3">
<Button type="submit" variant="secondary" disabled={savingPassword} className="flex items-center gap-2">
{savingPassword && <Loader2 className="h-4 w-4 animate-spin" />}
{t('settings:profile.actions.updatePassword', 'Passwort aktualisieren')}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
setPasswordErrors({});
}}
>
{t('common:actions.reset', 'Zurücksetzen')}
</Button>
</div>
</form>
</CardContent>
</Card>
</AdminLayout>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { AlertTriangle, LogOut, Palette } from 'lucide-react';
import { AlertTriangle, LogOut, Palette, UserCog } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
@@ -10,7 +10,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { AdminLayout } from '../components/AdminLayout';
import { useAuth } from '../auth/context';
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH } from '../constants';
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
import {
getNotificationPreferences,
updateNotificationPreferences,
@@ -33,7 +34,10 @@ export default function SettingsPage() {
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | null>(null);
function handleLogout() {
logout({ redirect: ADMIN_LOGIN_PATH });
const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
let marketingUrl = buildMarketingLoginUrl(targetPath);
marketingUrl += marketingUrl.includes('?') ? '&reset-auth=1' : '?reset-auth=1';
logout({ redirect: marketingUrl });
}
React.useEffect(() => {
@@ -101,6 +105,9 @@ export default function SettingsPage() {
<Button variant="destructive" onClick={handleLogout} className="flex items-center gap-2">
<LogOut className="h-4 w-4" /> Abmelden
</Button>
<Button variant="secondary" onClick={() => navigate(ADMIN_PROFILE_PATH)} className="flex items-center gap-2">
<UserCog className="h-4 w-4" /> {t('settings.profile.actions.openProfile', 'Profil bearbeiten')}
</Button>
<Button variant="ghost" onClick={() => navigate(-1)}>
Abbrechen
</Button>

View File

@@ -1,9 +1,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ADMIN_HOME_PATH } from '../constants';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget } from '../lib/returnTo';
const highlights = [
{
@@ -27,8 +25,23 @@ const highlights = [
];
export default function WelcomeTeaserPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [isRedirecting, setIsRedirecting] = React.useState(false);
const handleLoginRedirect = React.useCallback(() => {
if (isRedirecting) {
return;
}
setIsRedirecting(true);
const params = new URLSearchParams(window.location.search);
const rawReturnTo = params.get('return_to');
const { finalTarget, encodedFinal } = resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH);
const oauthStartPath = buildAdminOAuthStartPath(finalTarget, encodedFinal);
const marketingLoginUrl = buildMarketingLoginUrl(oauthStartPath);
window.location.href = marketingLoginUrl;
}, [isRedirecting]);
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-800">
@@ -48,9 +61,10 @@ export default function WelcomeTeaserPage() {
<button
type="button"
className="group inline-flex items-center justify-center gap-2 rounded-full border border-transparent bg-white px-6 py-3 text-base font-semibold text-rose-500 shadow-sm transition hover:border-rose-200 hover:text-rose-600"
onClick={() => login(ADMIN_HOME_PATH)}
onClick={handleLoginRedirect}
disabled={isRedirecting}
>
Ich habe bereits Zugang
{isRedirecting ? 'Weiterleitung ...' : 'Ich habe bereits Zugang'}
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" />
</button>
</div>

View File

@@ -18,14 +18,19 @@ import TaskCollectionsPage from './pages/TaskCollectionsPage';
import EmotionsPage from './pages/EmotionsPage';
import AuthCallbackPage from './pages/AuthCallbackPage';
import WelcomeTeaserPage from './pages/WelcomeTeaserPage';
import LoginStartPage from './pages/LoginStartPage';
import ProfilePage from './pages/ProfilePage';
import LogoutPage from './pages/LogoutPage';
import { useAuth } from './auth/context';
import {
ADMIN_BASE_PATH,
ADMIN_DEFAULT_AFTER_LOGIN_PATH,
ADMIN_HOME_PATH,
ADMIN_LOGIN_PATH,
ADMIN_LOGIN_START_PATH,
ADMIN_PUBLIC_LANDING_PATH,
} from './constants';
import { consumeLastDestination } from './lib/returnTo';
import WelcomeLandingPage from './onboarding/pages/WelcomeLandingPage';
import WelcomePackagesPage from './onboarding/pages/WelcomePackagesPage';
import WelcomeEventSetupPage from './onboarding/pages/WelcomeEventSetupPage';
@@ -44,7 +49,7 @@ function RequireAuth() {
}
if (status === 'unauthenticated') {
return <Navigate to={ADMIN_LOGIN_PATH} state={{ from: location }} replace />;
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
}
return <Outlet />;
@@ -52,6 +57,7 @@ function RequireAuth() {
function LandingGate() {
const { status } = useAuth();
const lastDestinationRef = React.useRef<string | null>(null);
if (status === 'loading') {
return (
@@ -62,7 +68,11 @@ function LandingGate() {
}
if (status === 'authenticated') {
return <Navigate to={ADMIN_HOME_PATH} replace />;
if (lastDestinationRef.current === null) {
lastDestinationRef.current = consumeLastDestination() ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH;
}
return <Navigate to={lastDestinationRef.current ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
}
return <WelcomeTeaserPage />;
@@ -75,6 +85,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <LandingGate /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'start', element: <LoginStartPage /> },
{ path: 'logout', element: <LogoutPage /> },
{ path: 'auth/callback', element: <AuthCallbackPage /> },
{
@@ -96,6 +107,7 @@ export const router = createBrowserRouter([
{ path: 'emotions', element: <EmotionsPage /> },
{ path: 'billing', element: <BillingPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'settings/profile', element: <ProfilePage /> },
{ path: 'welcome', element: <WelcomeLandingPage /> },
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },

View File

@@ -5,7 +5,7 @@ import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, Sid
import { dashboard } from '@/routes';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
import { BookOpen, Folder, LayoutGrid, UserRound } from 'lucide-react';
import AppLogo from './app-logo';
const mainNavItems: NavItem[] = [
@@ -14,6 +14,11 @@ const mainNavItems: NavItem[] = [
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Profil',
href: '/profile',
icon: UserRound,
},
];
const footerNavItems: NavItem[] = [

View File

@@ -1,8 +1,17 @@
import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout';
export default function AuthLayout({ children, title, description, ...props }: { children: React.ReactNode; title: string; description: string }) {
interface AuthLayoutProps {
children: React.ReactNode;
title: string;
description: string;
name?: string;
logoSrc?: string;
logoAlt?: string;
}
export default function AuthLayout({ children, title, description, name, logoSrc, logoAlt }: AuthLayoutProps) {
return (
<AuthLayoutTemplate title={title} description={description} {...props}>
<AuthLayoutTemplate title={title} description={description} name={name} logoSrc={logoSrc} logoAlt={logoAlt}>
{children}
</AuthLayoutTemplate>
);

View File

@@ -1,6 +1,6 @@
import AppLogoIcon from '@/components/app-logo-icon';
import { Button } from '@/components/ui/button';
import { home, packages } from '@/routes';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { Link } from '@inertiajs/react';
import { Sparkles, Camera, ShieldCheck } from 'lucide-react';
import { type PropsWithChildren } from 'react';
@@ -10,10 +10,14 @@ interface AuthLayoutProps {
name?: string;
title?: string;
description?: string;
logoSrc?: string;
logoAlt?: string;
}
export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
export default function AuthSimpleLayout({ children, title, description, name, logoSrc, logoAlt }: PropsWithChildren<AuthLayoutProps>) {
const { t } = useTranslation('auth');
const { localizedPath } = useLocalizedRoutes();
const brandLabel = name ?? 'Fotospiel';
const highlights = [
{
@@ -85,7 +89,7 @@ export default function AuthSimpleLayout({ children, title, description }: Props
<p>{t('login.hero_footer.subline', 'Entdecke unsere Packages und erlebe Fotospiel live.')}</p>
</div>
<Button asChild variant="secondary" className="h-10 rounded-full bg-white px-5 text-sm font-semibold text-gray-900 shadow-md shadow-white/30 transition hover:bg-white/90">
<Link href={packages()}>{t('login.hero_footer.cta', 'Packages entdecken')}</Link>
<Link href={localizedPath('/packages')}>{t('login.hero_footer.cta', 'Packages entdecken')}</Link>
</Button>
</div>
</div>
@@ -94,12 +98,19 @@ export default function AuthSimpleLayout({ children, title, description }: Props
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-pink-400 via-fuchsia-400 to-sky-400" aria-hidden />
<div className="relative z-10 flex flex-col gap-8">
<div className="flex flex-col items-center gap-4 text-center">
<Link href={home()} className="group flex flex-col items-center gap-3 font-medium">
<Link href={localizedPath('/')} className="group flex flex-col items-center gap-3 font-medium">
{logoSrc ? (
<img
src={logoSrc}
alt={logoAlt ?? brandLabel}
className="h-16 w-auto transition duration-300 group-hover:scale-105"
/>
) : (
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-[#ff8ab4] to-[#a855f7] shadow-lg shadow-pink-400/40 transition duration-300 group-hover:scale-105">
<AppLogoIcon className="size-8 fill-white" aria-hidden />
</span>
<span className="text-2xl font-semibold font-display text-gray-900 dark:text-white">Fotospiel</span>
<span className="sr-only">{title}</span>
)}
<span className="text-2xl font-semibold font-display text-gray-900 dark:text-white">{brandLabel}</span>
</Link>
<div className="space-y-2">

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const ProfileAccount = () => {
const { data, setData, post, processing, errors } = useForm({
name: '',
email: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/profile/account');
};
return (
<div className="container mx-auto py-8">
<Card>
<CardHeader>
<CardTitle>Account bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} />
{errors.name && <p className="text-red-500 text-sm">{errors.name}</p>}
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={data.email} onChange={(e) => setData('email', e.target.value)} />
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
</div>
<Button type="submit" disabled={processing}>Speichern</Button>
</form>
</CardContent>
</Card>
</div>
);
};
export default ProfileAccount;

View File

@@ -1,38 +1,375 @@
import React from 'react';
import { usePage } from '@inertiajs/react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import Account from './Account';
import Orders from './Orders';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useMemo, useRef } from 'react';
import { Transition } from '@headlessui/react';
import { Head, Form, Link, usePage } from '@inertiajs/react';
import { CalendarClock, CheckCircle2, MailWarning, ReceiptText } from 'lucide-react';
const ProfileIndex = () => {
const { user } = usePage().props as any;
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import InputError from '@/components/input-error';
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem, type SharedData } from '@/types';
return (
<div className="container mx-auto py-8 space-y-6">
<Card>
<CardHeader>
<CardTitle>Mein Profil</CardTitle>
</CardHeader>
<CardContent>
<p>Hallo, {user.name}!</p>
<p>Email: {user.email}</p>
</CardContent>
</Card>
<Tabs defaultValue="account" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="orders">Bestellungen</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Account />
</TabsContent>
<TabsContent value="orders">
<Orders />
</TabsContent>
</Tabs>
</div>
);
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Separator } from '@/components/ui/separator';
import { send as resendVerificationRoute } from '@/routes/verification';
type ProfilePageProps = {
userData: {
id: number;
name: string;
email: string;
username?: string | null;
preferredLocale?: string | null;
emailVerifiedAt: string | null;
mustVerifyEmail: boolean;
};
tenant: {
id: number;
name: string;
eventCreditsBalance: number | null;
subscriptionStatus: string | null;
subscriptionExpiresAt: string | null;
activePackage: {
name: string;
price: number | null;
expiresAt: string | null;
remainingEvents: number | null;
} | null;
} | null;
purchases: Array<{
id: number;
packageName: string;
price: number | null;
purchasedAt: string | null;
type: string | null;
provider: string | null;
}>;
};
export default ProfileIndex;
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Profil',
href: '/profile',
},
];
export default function ProfileIndex() {
const { userData, tenant, purchases, supportedLocales, locale } = usePage<SharedData & ProfilePageProps>().props;
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
}), [locale]);
const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
}), [locale]);
const registrationDate = useMemo(() => {
if (!userData.emailVerifiedAt) {
return null;
}
try {
return dateFormatter.format(new Date(userData.emailVerifiedAt));
} catch (error) {
return null;
}
}, [userData.emailVerifiedAt, dateFormatter]);
const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—');
const formatPrice = (price: number | null) => (price === null ? '—' : currencyFormatter.format(price));
const localeOptions = (supportedLocales ?? ['de', 'en']).map((value) => ({
label: value.toUpperCase(),
value,
}));
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Profil" />
<div className="flex flex-1 flex-col gap-6 pb-12">
<Card className="border-border/60 bg-gradient-to-br from-background to-muted/50 shadow-sm">
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-2xl font-semibold">Hallo, {userData.name || userData.email}</CardTitle>
<p className="text-sm text-muted-foreground">
Hier verwaltest du deine Zugangsdaten, sprichst mit uns in deiner Lieblingssprache und behältst alle Buchungen im Blick.
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
<Badge variant={userData.emailVerifiedAt ? 'secondary' : 'outline'} className="flex items-center gap-2">
{userData.emailVerifiedAt ? <CheckCircle2 className="size-4 text-emerald-500" /> : <MailWarning className="size-4 text-amber-500" />}
{userData.emailVerifiedAt ? 'E-Mail bestätigt' : 'Bestätigung ausstehend'}
</Badge>
{registrationDate && (
<p className="text-xs text-muted-foreground">Aktiv seit {registrationDate}</p>
)}
</div>
</CardHeader>
{!userData.emailVerifiedAt && userData.mustVerifyEmail && (
<CardContent>
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-900 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-50">
<MailWarning className="size-5" />
<div>
<AlertTitle>E-Mail-Bestätigung ausstehend</AlertTitle>
<AlertDescription>
Bestätige deine E-Mail-Adresse, damit wir dich über Uploads, Rechnungen und Event-Updates informieren können.
<div className="mt-3">
<Link href={resendVerificationRoute()} as="button" method="post" className="text-sm font-medium underline underline-offset-4">
Bestätigungslink erneut senden
</Link>
</div>
</AlertDescription>
</div>
</Alert>
</CardContent>
)}
</Card>
<div className="grid gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2">
<AccountForm userData={userData} localeOptions={localeOptions} />
</Card>
<Card>
<PasswordForm />
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle>Abonnements &amp; Pakete</CardTitle>
<p className="text-sm text-muted-foreground">
Hier findest du die wichtigsten Daten zu deinem aktuellen Paket und deinen letzten Buchungen.
</p>
</div>
{tenant?.activePackage ? (
<Badge variant="outline" className="flex items-center gap-2">
<CalendarClock className="size-4" /> Läuft bis {formatDate(tenant.activePackage.expiresAt)}
</Badge>
) : (
<Badge variant="outline">Kein aktives Paket</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
{tenant?.activePackage ? (
<div className="grid gap-3 rounded-lg border border-dashed border-muted-foreground/30 bg-muted/30 p-4 md:grid-cols-2">
<div>
<h3 className="text-base font-medium leading-tight">{tenant.activePackage.name}</h3>
<p className="text-xs text-muted-foreground">{tenant.eventCreditsBalance ?? 0} Credits verfügbar · {tenant.activePackage.remainingEvents ?? 0} Events inklusive</p>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Status</span>
<span className="font-medium capitalize">{tenant.subscriptionStatus ?? 'aktiv'}</span>
</div>
<div className="flex items-center justify-between">
<span>Verlängerung</span>
<span>{formatDate(tenant.subscriptionExpiresAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>Preis</span>
<span>{formatPrice(tenant.activePackage.price)}</span>
</div>
</div>
</div>
) : (
<Alert className="border border-dashed border-muted-foreground/40 bg-muted/20">
<AlertDescription className="text-sm text-muted-foreground">
Du hast aktuell kein aktives Paket. Sichere dir jetzt Credits oder ein Komplettpaket, um neue Events zu planen.
</AlertDescription>
</Alert>
)}
<Separator />
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ReceiptText className="size-4" /> Letzte Buchungen
</div>
{purchases.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-sm text-muted-foreground text-center">
Noch keine Buchungen vorhanden. Schaue im Dashboard vorbei, um passende Pakete zu finden.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead className="hidden sm:table-cell">Typ</TableHead>
<TableHead className="hidden md:table-cell">Anbieter</TableHead>
<TableHead className="hidden md:table-cell">Datum</TableHead>
<TableHead className="text-right">Preis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">{purchase.packageName}</TableCell>
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
<TableCell className="text-right font-medium">{formatPrice(purchase.price)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
</AppLayout>
);
}
function AccountForm({ userData, localeOptions }: { userData: ProfilePageProps['userData']; localeOptions: Array<{ label: string; value: string }> }) {
return (
<>
<CardHeader>
<CardTitle>Profilinformationen</CardTitle>
<p className="text-sm text-muted-foreground">Aktualisiere deine Kontaktdaten und die Standardsprache für E-Mails und Oberfläche.</p>
</CardHeader>
<CardContent>
<Form
{...ProfileController.update.form()}
options={{ preserveScroll: true }}
className="space-y-6"
>
{({ processing, recentlySuccessful, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="name">Vollständiger Name</Label>
<Input id="name" name="name" defaultValue={userData.name ?? ''} autoComplete="name" placeholder="Max Mustermann" />
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input id="email" type="email" name="email" defaultValue={userData.email ?? ''} autoComplete="username" />
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="username">Benutzername</Label>
<Input id="username" name="username" defaultValue={userData.username ?? ''} autoComplete="username" placeholder="mein-eventname" />
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="preferred_locale">Bevorzugte Sprache</Label>
<select
id="preferred_locale"
name="preferred_locale"
defaultValue={userData.preferredLocale ?? localeOptions[0]?.value ?? 'de'}
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{localeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<InputError message={errors.preferred_locale} />
</div>
<CardFooter className="px-0">
<div className="flex items-center gap-4">
<Button disabled={processing}>Änderungen speichern</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-muted-foreground">Gespeichert</p>
</Transition>
</div>
</CardFooter>
</>
)}
</Form>
</CardContent>
</>
);
}
function PasswordForm() {
const passwordInputRef = useRef<HTMLInputElement>(null);
const currentPasswordInputRef = useRef<HTMLInputElement>(null);
return (
<>
<CardHeader>
<CardTitle>Sicherheit &amp; Passwort</CardTitle>
<p className="text-sm text-muted-foreground">Vergebe ein starkes Passwort, um dein Konto bestmöglich zu schützen.</p>
</CardHeader>
<CardContent>
<Form
{...PasswordController.update.form()}
options={{ preserveScroll: true }}
resetOnError={['password', 'password_confirmation', 'current_password']}
resetOnSuccess
onError={(errors) => {
if (errors.password) {
passwordInputRef.current?.focus();
}
if (errors.current_password) {
currentPasswordInputRef.current?.focus();
}
}}
className="space-y-6"
>
{({ errors, processing, recentlySuccessful }) => (
<>
<div className="grid gap-2">
<Label htmlFor="current_password">Aktuelles Passwort</Label>
<Input id="current_password" name="current_password" type="password" ref={currentPasswordInputRef} autoComplete="current-password" />
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Neues Passwort</Label>
<Input id="password" name="password" type="password" ref={passwordInputRef} autoComplete="new-password" />
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Bestätigung</Label>
<Input id="password_confirmation" name="password_confirmation" type="password" autoComplete="new-password" />
<InputError message={errors.password_confirmation} />
</div>
<CardFooter className="px-0">
<div className="flex items-center gap-4">
<Button disabled={processing}>Passwort speichern</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-muted-foreground">Aktualisiert</p>
</Transition>
</div>
</CardFooter>
</>
)}
</Form>
</CardContent>
</>
);
}

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { usePage } from '@inertiajs/react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { format } from 'date-fns';
interface Purchase {
id: number;
created_at: string;
package: {
name: string;
price: number;
};
status: string;
}
const ProfileOrders = () => {
const page = usePage<{ purchases?: Purchase[] }>();
const purchases = page.props.purchases ?? [];
return (
<div className="container mx-auto py-8">
<Card>
<CardHeader>
<CardTitle>Bestellungen</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead>Preis</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell>{purchase.package.name}</TableCell>
<TableCell>{purchase.package.price} </TableCell>
<TableCell>{format(new Date(purchase.created_at), 'dd.MM.yyyy')}</TableCell>
<TableCell>
<Badge variant={purchase.status === 'completed' ? 'default' : 'secondary'}>
{purchase.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{purchases.length === 0 && (
<p className="text-center text-muted-foreground py-8">Keine Bestellungen gefunden.</p>
)}
</CardContent>
</Card>
</div>
);
};
export default ProfileOrders;

View File

@@ -1,4 +1,5 @@
import { FormEvent, useEffect, useState } from 'react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Head, useForm } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import InputError from '@/components/input-error';
@@ -8,6 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth-layout';
import AppLayout from '@/layouts/app/AppLayout';
import { register } from '@/routes';
import { request } from '@/routes/password';
import { LoaderCircle } from 'lucide-react';
@@ -19,12 +21,15 @@ interface LoginProps {
export default function Login({ status, canResetPassword }: LoginProps) {
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const [rawReturnTo, setRawReturnTo] = useState<string | null>(null);
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
const { t } = useTranslation('auth');
const { data, setData, post, processing, errors, clearErrors } = useForm({
email: '',
login: '',
password: '',
remember: false,
return_to: '',
});
const submit = (e: FormEvent<HTMLFormElement>) => {
@@ -38,6 +43,19 @@ export default function Login({ status, canResetPassword }: LoginProps) {
const errorKeys = Object.keys(errors);
const hasErrors = errorKeys.length > 0;
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const searchParams = new URLSearchParams(window.location.search);
setRawReturnTo(searchParams.get('return_to'));
}, []);
useEffect(() => {
setData('return_to', rawReturnTo ?? '');
}, [rawReturnTo, setData]);
useEffect(() => {
if (!hasTriedSubmit) {
return;
@@ -56,42 +74,70 @@ export default function Login({ status, canResetPassword }: LoginProps) {
}
}, [errors, hasTriedSubmit]);
const googleHref = useMemo(() => {
if (!rawReturnTo) {
return '/event-admin/auth/google';
}
const params = new URLSearchParams({
return_to: rawReturnTo,
});
return `/event-admin/auth/google?${params.toString()}`;
}, [rawReturnTo]);
const handleGoogleLogin = () => {
if (typeof window === 'undefined') {
return;
}
setIsRedirectingToGoogle(true);
window.location.href = googleHref;
};
return (
<AuthLayout title={t('login.title')} description={t('login.description')}>
<AuthLayout
title={t('login.title')}
description={t('login.description')}
name={t('login.brand', t('login.title'))}
logoSrc="/logo-transparent-lg.png"
logoAlt={t('login.logo_alt', 'Die Fotospiel.App')}
>
<Head title={t('login.title')} />
<form
onSubmit={submit}
className="relative flex flex-col gap-6 overflow-hidden rounded-3xl border border-gray-200/70 bg-white/80 p-6 shadow-xl shadow-rose-200/40 backdrop-blur-sm transition dark:border-gray-800/80 dark:bg-gray-900/70"
>
<input type="hidden" name="return_to" value={data.return_to ?? ''} />
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-pink-400/80 via-rose-400/70 to-sky-400/70" aria-hidden />
<div className="grid gap-6 pt-2 sm:pt-4">
<div className="grid gap-2">
<Label htmlFor="email" className="text-sm font-semibold text-gray-700 dark:text-gray-200">
<Label htmlFor="login" className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{t('login.email')}
</Label>
<Input
id="email"
type="email"
name="email"
id="login"
type="text"
name="login"
required
autoFocus
tabIndex={1}
autoComplete="email"
autoComplete="username"
placeholder={t('login.email_placeholder')}
value={data.email}
value={data.login}
onChange={(e) => {
setData('email', e.target.value);
if (errors.email) {
clearErrors('email');
setData('login', e.target.value);
if (errors.login) {
clearErrors('login');
}
}}
className="h-12 rounded-xl border-gray-200/80 bg-white/90 px-4 text-base shadow-inner shadow-gray-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-100"
/>
<InputError
key={`error-email`}
message={errors.email}
key={`error-login`}
message={errors.login}
className="text-sm font-medium text-rose-600 dark:text-rose-400"
/>
</div>
@@ -178,6 +224,32 @@ export default function Login({ status, canResetPassword }: LoginProps) {
)}
</div>
<div className="space-y-3">
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
<span className="h-px flex-1 bg-gray-200 dark:bg-gray-800" aria-hidden />
{t('login.oauth_divider', 'oder')}
<span className="h-px flex-1 bg-gray-200 dark:bg-gray-800" aria-hidden />
</div>
<Button
type="button"
variant="outline"
onClick={handleGoogleLogin}
disabled={processing || isRedirectingToGoogle}
className="flex h-12 w-full items-center justify-center gap-3 rounded-xl border-gray-200/80 bg-white/90 text-sm font-semibold text-gray-700 shadow-inner shadow-gray-200/40 transition hover:bg-white dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-100 dark:hover:bg-gray-900/80"
>
{isRedirectingToGoogle ? (
<LoaderCircle className="h-5 w-5 animate-spin" aria-hidden />
) : (
<GoogleIcon className="h-5 w-5" />
)}
<span>{t('login.google_cta')}</span>
</Button>
<p className="text-center text-xs text-muted-foreground dark:text-gray-300">
{t('login.google_helper', 'Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.')}
</p>
</div>
<div className="rounded-2xl border border-gray-200/60 bg-gray-50/80 p-4 text-center text-sm text-muted-foreground shadow-inner dark:border-gray-800/70 dark:bg-gray-900/60">
{t('login.no_account')}{' '}
<TextLink
@@ -192,3 +264,28 @@ export default function Login({ status, canResetPassword }: LoginProps) {
</AuthLayout>
);
}
Login.layout = (page: ReactNode) => <AppLayout header={<></>}>{page}</AppLayout>;
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
fill="#4285F4"
d="M23.52 12.272c0-.851-.076-1.67-.217-2.455H12v4.639h6.44a5.51 5.51 0 0 1-2.393 3.622v3.01h3.88c2.271-2.093 3.593-5.18 3.593-8.816Z"
/>
<path
fill="#34A853"
d="M12 24c3.24 0 5.957-1.073 7.943-2.912l-3.88-3.01c-1.073.72-2.446 1.147-4.063 1.147-3.124 0-5.773-2.111-6.717-4.954H1.245v3.11C3.221 21.64 7.272 24 12 24Z"
/>
<path
fill="#FBBC05"
d="M5.283 14.27a7.2 7.2 0 0 1 0-4.54V6.62H1.245a11.996 11.996 0 0 0 0 10.76l4.038-3.11Z"
/>
<path
fill="#EA4335"
d="M12 4.75c1.761 0 3.344.606 4.595 1.794l3.447-3.447C17.957 1.012 15.24 0 12 0 7.272 0 3.221 2.36 1.245 6.62l4.038 3.11C6.227 6.861 8.876 4.75 12 4.75Z"
/>
</svg>
);
}

View File

@@ -1,8 +1,85 @@
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import { useMemo, useState } from 'react';
import { AlertTriangle, CalendarDays, Camera, ClipboardList, Package, Sparkles, TrendingUp, UserRound, Key } from 'lucide-react';
import { Head, Link, router, usePage } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import { dashboard } from '@/routes';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/react';
import { edit as passwordSettings } from '@/routes/password';
import profileRoutes from '@/routes/profile';
import { send as resendVerificationRoute } from '@/routes/verification';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
type DashboardMetrics = {
total_events: number;
active_events: number;
published_events: number;
events_with_tasks: number;
upcoming_events: number;
new_photos: number;
task_progress: number;
credit_balance: number | null;
active_package: {
name: string;
expires_at: string | null;
remaining_events: number | null;
} | null;
};
type DashboardEvent = {
id: number;
name: string;
slug: string | null;
status: string | null;
isActive: boolean;
date: string | null;
photosCount: number;
tasksCount: number;
joinTokensCount: number;
};
type DashboardPurchase = {
id: number;
packageName: string;
price: number | null;
purchasedAt: string | null;
type: string | null;
provider: string | null;
};
type TenantSummary = {
id: number;
name: string;
eventCreditsBalance: number | null;
subscriptionStatus: string | null;
subscriptionExpiresAt: string | null;
activePackage: {
name: string;
price: number | null;
expiresAt: string | null;
remainingEvents: number | null;
} | null;
} | null;
type DashboardPageProps = {
metrics: DashboardMetrics | null;
upcomingEvents: DashboardEvent[];
recentPurchases: DashboardPurchase[];
latestPurchase: DashboardPurchase | null;
tenant: TenantSummary;
emailVerification: {
mustVerify: boolean;
verified: boolean;
};
};
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -12,24 +89,393 @@ const breadcrumbs: BreadcrumbItem[] = [
];
export default function Dashboard() {
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale } = usePage<SharedData & DashboardPageProps>().props;
const [verificationSent, setVerificationSent] = useState(false);
const [sendingVerification, setSendingVerification] = useState(false);
const needsEmailVerification = emailVerification.mustVerify && !emailVerification.verified;
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
}), [locale]);
const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
}), [locale]);
const taskProgress = metrics?.task_progress ?? 0;
const checklistItems = [
{
key: 'verify-email',
title: 'E-Mail-Adresse bestätigen',
description: 'Bestätige deine Adresse, um Einladungen zu versenden und Benachrichtigungen zu erhalten.',
done: !needsEmailVerification,
},
{
key: 'create-event',
title: 'Dein erstes Event erstellen',
description: 'Starte mit einem Event-Blueprint und passe Agenda, Uploadregeln und Branding an.',
done: (metrics?.total_events ?? 0) > 0,
},
{
key: 'publish-event',
title: 'Event veröffentlichen',
description: 'Schalte dein Event frei, damit Gäste über den Link Fotos hochladen können.',
done: (metrics?.published_events ?? 0) > 0,
},
{
key: 'invite-guests',
title: 'Gästelink teilen',
description: 'Nutze QR-Code oder Link, um Gäste einzuladen und erste Uploads zu sammeln.',
done: upcomingEvents.some((event) => event.joinTokensCount > 0),
},
{
key: 'collect-photos',
title: 'Fotos sammeln',
description: 'Spare Zeit bei der Nachbereitung: neue Uploads erscheinen direkt in deinem Event.',
done: (metrics?.new_photos ?? 0) > 0,
},
];
const quickActions = [
{
key: 'tenant-admin',
label: 'Event-Admin öffnen',
description: 'Detaillierte Eventverwaltung, Moderation und Live-Features.',
href: '/event-admin',
icon: Sparkles,
},
{
key: 'profile',
label: 'Profil verwalten',
description: 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.',
href: profileRoutes.index().url,
icon: UserRound,
},
{
key: 'password',
label: 'Passwort aktualisieren',
description: 'Sichere dein Konto mit einem aktuellen Passwort.',
href: passwordSettings().url,
icon: Key,
},
{
key: 'packages',
label: 'Pakete entdecken',
description: 'Mehr Events oder Speicher buchen du bleibst flexibel.',
href: `/${locale ?? 'de'}/packages`,
icon: Package,
},
];
const stats = [
{
key: 'active-events',
label: 'Aktive Events',
value: metrics?.active_events ?? 0,
description: metrics?.active_events
? 'Events sind live und für Gäste sichtbar.'
: 'Noch kein Event veröffentlicht starte heute!',
icon: CalendarDays,
},
{
key: 'upcoming-events',
label: 'Bevorstehende Events',
value: metrics?.upcoming_events ?? 0,
description: metrics?.upcoming_events
? 'Planung läuft behalte Checklisten und Aufgaben im Blick.'
: 'Lass dich vom Assistenten beim Planen unterstützen.',
icon: TrendingUp,
},
{
key: 'new-photos',
label: 'Neue Fotos (7 Tage)',
value: metrics?.new_photos ?? 0,
description: metrics && metrics.new_photos > 0
? 'Frisch eingetroffene Erinnerungen deiner Gäste.'
: 'Sammle erste Uploads über QR-Code oder Direktlink.',
icon: Camera,
},
{
key: 'credit-balance',
label: 'Event Credits',
value: tenant?.eventCreditsBalance ?? 0,
description: tenant?.eventCreditsBalance
? 'Verfügbare Credits für neue Events.'
: 'Buche Pakete oder Credits, um weitere Events zu planen.',
icon: Package,
extra: metrics?.active_package?.remaining_events ?? null,
},
{
key: 'task-progress',
label: 'Event-Checkliste',
value: `${taskProgress}%`,
description: taskProgress > 0
? 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.'
: 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.',
icon: ClipboardList,
},
];
const handleResendVerification = () => {
setSendingVerification(true);
setVerificationSent(false);
router.post(resendVerificationRoute(), {}, {
preserveScroll: true,
onSuccess: () => setVerificationSent(true),
onFinish: () => setSendingVerification(false),
});
};
const renderPrice = (price: number | null) => {
if (price === null) {
return '—';
}
try {
return currencyFormatter.format(price);
} catch (error) {
return `${price.toFixed(2)}`;
}
};
const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—');
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<div className="mt-8 flex flex-1 flex-col gap-8 pb-16">
{needsEmailVerification && (
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-950 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-100">
<AlertTriangle className="size-5" />
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle>Bitte bestätige deine E-Mail-Adresse</AlertTitle>
<AlertDescription>
Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist.
Prüfe dein Postfach oder fordere einen neuen Link an.
</AlertDescription>
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<div className="flex flex-col gap-2 sm:items-end">
<Button size="sm" variant="outline" onClick={handleResendVerification} disabled={sendingVerification}>
{sendingVerification ? 'Sende...' : 'Link erneut senden'}
</Button>
{verificationSent && <span className="text-xs text-muted-foreground">Wir haben dir gerade einen neuen Bestätigungslink geschickt.</span>}
</div>
</div>
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</Alert>
)}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.key} className="bg-gradient-to-br from-background to-muted/60 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-2 pb-2">
<div>
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
</div>
<span className="rounded-full border bg-background/70 p-2 text-muted-foreground">
<stat.icon className="size-5" />
</span>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>{stat.description}</p>
{stat.key === 'credit-balance' && stat.extra !== null && (
<div className="text-xs text-muted-foreground">{stat.extra} weitere Events im aktuellen Paket enthalten.</div>
)}
{stat.key === 'task-progress' && (
<div className="space-y-1">
<Progress value={taskProgress} />
<span className="text-xs text-muted-foreground">{taskProgress}% deiner Event-Checkliste erledigt.</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Bevorstehende Events</CardTitle>
<p className="text-sm text-muted-foreground">Status, Uploads und Aufgaben deiner nächsten Events im Überblick.</p>
</div>
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>
{upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{upcomingEvents.length === 0 && (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-6 text-center">
<p className="text-sm text-muted-foreground">
Plane dein erstes Event und begleite den gesamten Ablauf vom Briefing bis zur Nachbereitung direkt hier im Dashboard.
</p>
</div>
)}
{upcomingEvents.map((event) => (
<div key={event.id} className="rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold leading-tight">{event.name}</h3>
<p className="text-sm text-muted-foreground">
{formatDate(event.date)} · {event.status === 'published' || event.isActive ? 'Live' : 'In Vorbereitung'}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-1">{event.photosCount} Fotos</span>
<span className="rounded-full bg-muted px-2 py-1">{event.tasksCount} Aufgaben</span>
<span className="rounded-full bg-muted px-2 py-1">{event.joinTokensCount} Links</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Nächstes Paket &amp; Credits</CardTitle>
<p className="text-sm text-muted-foreground">Behalte Laufzeiten und verfügbaren Umfang stets im Blick.</p>
</CardHeader>
<CardContent className="space-y-3">
{tenant?.activePackage ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{tenant.activePackage.name}</span>
<Badge variant="outline">{tenant.activePackage.remainingEvents ?? 0} Events übrig</Badge>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Läuft ab</span>
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>Preis</span>
<span>{renderPrice(tenant.activePackage.price)}</span>
</div>
</div>
{latestPurchase && (
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
Zuletzt gebucht am {formatDate(latestPurchase.purchasedAt)} via {latestPurchase.provider?.toUpperCase() ?? 'Checkout'}.
</div>
)}
</div>
) : (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
Noch kein aktives Paket. <Link href={`/${locale ?? 'de'}/packages`} className="font-medium underline underline-offset-4">Jetzt Paket auswählen</Link> und direkt Events planen.
</div>
)}
<Separator />
<div className="flex items-center justify-between text-sm">
<span>Event Credits insgesamt</span>
<span className="font-medium">{tenant?.eventCreditsBalance ?? 0}</span>
</div>
<div className="text-xs text-muted-foreground">
Credits werden bei neuen Events automatisch verbraucht. Zusätzliche Kontingente kannst du jederzeit buchen.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Dein Start in 5 Schritten</CardTitle>
<p className="text-sm text-muted-foreground">Folge den wichtigsten Schritten, um dein Event reibungslos aufzusetzen.</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-3">
{checklistItems.map((item) => (
<div key={item.key} className="flex gap-3 rounded-md border border-border/60 bg-background/50 p-3">
<Checkbox checked={item.done} className="mt-1" readOnly />
<div>
<p className="text-sm font-medium leading-tight">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Aktuelle Buchungen</CardTitle>
<p className="text-sm text-muted-foreground">Verfolge deine gebuchten Pakete und Erweiterungen.</p>
</div>
<Badge variant="outline">{recentPurchases.length} Einträge</Badge>
</CardHeader>
<CardContent>
{recentPurchases.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-center text-sm text-muted-foreground">
Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead className="hidden sm:table-cell">Typ</TableHead>
<TableHead className="hidden md:table-cell">Anbieter</TableHead>
<TableHead className="hidden md:table-cell">Datum</TableHead>
<TableHead className="text-right">Preis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentPurchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">{purchase.packageName}</TableCell>
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
<TableCell className="text-right font-medium">{renderPrice(purchase.price)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Schnellzugriff</CardTitle>
<p className="text-sm text-muted-foreground">Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.</p>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{quickActions.map((action) => (
<div key={action.key} className="flex flex-col justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-4">
<div className="flex items-center gap-3">
<span className="rounded-full bg-muted p-2 text-muted-foreground">
<action.icon className="size-5" />
</span>
<div>
<p className="text-sm font-semibold leading-tight">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</div>
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm">
<Link href={action.href} prefetch>
Weiter
</Link>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
</AppLayout>
);

View File

@@ -28,6 +28,7 @@ export interface SharedData {
auth: Auth;
sidebarOpen: boolean;
supportedLocales?: string[];
locale?: string;
security?: {
csp?: {
scriptNonce?: string;

View File

@@ -2,7 +2,7 @@
"login_failed": "Diese Anmeldedaten wurden nicht gefunden.",
"login_success": "Sie sind nun eingeloggt.",
"registration_failed": "Registrierung fehlgeschlagen.",
"registration_success": "Registrierung erfolgreich <EFBFBD> bitte mit dem Kauf fortfahren.",
"registration_success": "Registrierung erfolgreich bitte mit dem Kauf fortfahren.",
"already_logged_in": "Sie sind bereits eingeloggt.",
"failed_credentials": "Diese Anmeldedaten wurden nicht gefunden.",
"header": {
@@ -19,7 +19,10 @@
"contact": "Kontakt"
},
"login": {
"title": "Anmelden",
"title": "Die Fotospiel.App",
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel.App",
"logo_alt": "Logo Die Fotospiel.App",
"username_or_email": "Username oder E-Mail",
"email": "E-Mail-Adresse",
"email_placeholder": "ihre@email.de",
@@ -27,24 +30,29 @@
"password_placeholder": "Ihr Passwort",
"remember": "Angemeldet bleiben",
"forgot": "Passwort vergessen?",
"submit": "Anmelden"
"submit": "Anmelden",
"oauth_divider": "oder",
"google_cta": "Mit Google anmelden",
"google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.",
"no_account": "Noch keinen Zugang?",
"sign_up": "Jetzt registrieren"
},
"register": {
"title": "Registrieren",
"name": "Vollst<EFBFBD>ndiger Name",
"name": "Vollständiger Name",
"username": "Username",
"email": "E-Mail-Adresse",
"password": "Passwort",
"password_confirmation": "Passwort best<EFBFBD>tigen",
"password_confirmation": "Passwort bestätigen",
"first_name": "Vorname",
"last_name": "Nachname",
"address": "Adresse",
"phone": "Telefonnummer",
"privacy_consent": "Ich stimme der Datenschutzerkl<EFBFBD>rung zu und akzeptiere die Verarbeitung meiner pers<EFBFBD>nlichen Daten.",
"privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.",
"submit": "Registrieren"
},
"verification": {
"notice": "Bitte best<EFBFBD>tigen Sie Ihre E-Mail-Adresse.",
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",
"resend": "E-Mail erneut senden"
}
}

View File

@@ -19,7 +19,10 @@
"contact": "Contact"
},
"login": {
"title": "Login",
"title": "Die Fotospiel.App",
"description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel.App",
"logo_alt": "Die Fotospiel.App logo",
"username_or_email": "Username or Email",
"email": "Email Address",
"email_placeholder": "your@email.com",
@@ -27,7 +30,12 @@
"password_placeholder": "Your password",
"remember": "Stay logged in",
"forgot": "Forgot password?",
"submit": "Login"
"submit": "Login",
"oauth_divider": "or",
"google_cta": "Continue with Google",
"google_helper": "Use your Google account to access the event dashboard securely.",
"no_account": "Don't have access yet?",
"sign_up": "Create an account"
},
"register": {
"title": "Register",

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
use App\Http\Controllers\Api\Tenant\EventTypeController;
use App\Http\Controllers\Api\Tenant\NotificationLogController;
use App\Http\Controllers\Api\Tenant\PhotoController;
use App\Http\Controllers\Api\Tenant\ProfileController;
use App\Http\Controllers\Api\Tenant\SettingsController;
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
use App\Http\Controllers\Api\Tenant\TaskController;
@@ -19,6 +20,9 @@ use App\Http\Controllers\Api\TenantPackageController;
use App\Http\Controllers\OAuthController;
use App\Http\Controllers\RevenueCatWebhookController;
use App\Http\Controllers\Tenant\CreditController;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->name('api.v1.')->group(function () {
@@ -26,7 +30,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->middleware('throttle:60,1')
->name('webhooks.revenuecat');
Route::middleware('throttle:oauth')->group(function () {
Route::middleware([EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, 'throttle:oauth'])->group(function () {
Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->name('oauth.authorize');
Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token');
});
@@ -57,6 +61,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
});
Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
Route::get('profile', [ProfileController::class, 'show'])->name('tenant.profile.show');
Route::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update');
Route::get('me', [OAuthController::class, 'me'])->name('tenant.me');
Route::get('dashboard', DashboardController::class)->name('tenant.dashboard');
Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index');

View File

@@ -4,12 +4,15 @@ use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\CheckoutGoogleController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\LegalPageController;
use App\Http\Controllers\LocaleController;
use App\Http\Controllers\MarketingController;
use App\Http\Controllers\PaddleCheckoutController;
use App\Http\Controllers\PaddleWebhookController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\Tenant\EventPhotoArchiveController;
use App\Http\Controllers\TenantAdminGoogleController;
use App\Models\Package;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@@ -227,15 +230,23 @@ Route::get('/buy/{packageId}', function (Request $request, int $packageId) use (
return redirect("/{$locale}/buy/{$packageId}", 301);
});
Route::get('/dashboard', function () {
return Inertia::render('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::get('/dashboard', DashboardController::class)
->middleware(['auth'])
->name('dashboard');
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'index'])
->name('profile.index');
});
Route::prefix('event-admin')->group(function () {
$renderAdmin = fn () => view('admin');
Route::get('/auth/callback', $renderAdmin)->name('tenant.admin.auth.callback');
Route::get('/login', $renderAdmin)->name('tenant.admin.login');
Route::get('/logout', $renderAdmin)->name('tenant.admin.logout');
Route::get('/auth/google', [TenantAdminGoogleController::class, 'redirect'])
->name('tenant.admin.google.redirect');
Route::get('/auth/google/callback', [TenantAdminGoogleController::class, 'callback'])
->name('tenant.admin.google.callback');
Route::get('/{view?}', $renderAdmin)
->where('view', '.*')
->name('tenant.admin.app');

View File

@@ -3,11 +3,9 @@
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Notifications\VerifyEmail;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
use Illuminate\Support\Facades\Auth;
use Tests\TestCase;
class LoginTest extends TestCase
{
@@ -27,7 +25,8 @@ class LoginTest extends TestCase
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$expectedDefault = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
$response->assertRedirect($expectedDefault);
$this->assertEquals('valid@example.com', Auth::user()->email);
}
@@ -45,7 +44,8 @@ class LoginTest extends TestCase
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$expectedDefault = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
$response->assertRedirect($expectedDefault);
$this->assertEquals('validuser', Auth::user()->username);
}
@@ -82,10 +82,32 @@ class LoginTest extends TestCase
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$expected = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
$response->assertRedirect($expected);
$response->assertSessionHas('success', 'Sie sind nun eingeloggt.');
}
public function test_login_honors_return_to_parameter()
{
$user = User::factory()->create([
'email' => 'return@example.com',
'password' => bcrypt('password'),
'email_verified_at' => now(),
]);
$target = route('tenant.admin.app', absolute: false);
$encoded = rtrim(strtr(base64_encode($target), '+/', '-_'), '=');
$response = $this->post(route('login.store'), [
'login' => 'return@example.com',
'password' => 'password',
'return_to' => $encoded,
]);
$this->assertAuthenticated();
$response->assertRedirect($target);
}
public function test_login_redirects_unverified_user_to_verification_notice()
{
$user = User::factory()->create([

View File

@@ -0,0 +1,102 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
use Mockery;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Tests\TestCase;
class TenantAdminGoogleControllerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_redirect_stores_return_to_and_issues_google_redirect(): void
{
$driver = Mockery::mock();
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
$driver->shouldReceive('scopes')->once()->with(['openid', 'profile', 'email'])->andReturnSelf();
$driver->shouldReceive('with')->once()->with(['prompt' => 'select_account'])->andReturnSelf();
$driver->shouldReceive('redirect')->once()->andReturn(new RedirectResponse('https://accounts.google.com'));
$encodedReturn = rtrim(strtr(base64_encode('http://localhost/test'), '+/', '-_'), '=');
$response = $this->get('/event-admin/auth/google?return_to='.$encodedReturn);
$response->assertRedirect('https://accounts.google.com');
$this->assertSame($encodedReturn, session('tenant_oauth_return_to'));
}
public function test_callback_logs_in_tenant_admin_and_redirects_to_encoded_target(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
]);
$socialiteUser = tap(new SocialiteUser)->map([
'id' => 'google-id-123',
'name' => 'Google Tenant Admin',
'email' => $user->email,
]);
$driver = Mockery::mock();
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
$driver->shouldReceive('user')->once()->andReturn($socialiteUser);
$targetUrl = 'http://localhost:8000/api/v1/oauth/authorize?foo=bar';
$encodedReturn = rtrim(strtr(base64_encode($targetUrl), '+/', '-_'), '=');
$this->withSession([
'tenant_oauth_return_to' => $encodedReturn,
]);
$response = $this->get('/event-admin/auth/google/callback');
$response->assertRedirect($targetUrl);
$this->assertAuthenticatedAs($user);
}
public function test_callback_redirects_back_when_user_not_found(): void
{
$socialiteUser = tap(new SocialiteUser)->map([
'id' => 'missing-user',
'name' => 'Unknown User',
'email' => 'unknown@example.com',
]);
$driver = Mockery::mock();
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
$driver->shouldReceive('user')->once()->andReturn($socialiteUser);
$response = $this->get('/event-admin/auth/google/callback');
$response->assertRedirect();
$this->assertStringContainsString('error=google_no_match', $response->headers->get('Location'));
$this->assertFalse(Auth::check());
}
public function test_callback_handles_socialite_failure(): void
{
$driver = Mockery::mock();
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
$driver->shouldReceive('user')->once()->andThrow(new \RuntimeException('boom'));
$response = $this->get('/event-admin/auth/google/callback');
$response->assertRedirect();
$this->assertStringContainsString('error=google_failed', $response->headers->get('Location'));
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Tests\Feature\Dashboard;
use App\Models\Event;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Photo;
use App\Models\Task;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class DashboardPageTest extends TestCase
{
use RefreshDatabase;
public function test_unverified_user_can_access_dashboard_with_summary_data(): void
{
$tenant = Tenant::factory()->create([
'event_credits_balance' => 4,
]);
$package = Package::factory()->reseller()->create([
'name_translations' => [
'de' => 'Premium Paket',
'en' => 'Premium Package',
],
'max_events_per_year' => 10,
]);
TenantPackage::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 149.00,
'purchased_at' => now()->subDay(),
'expires_at' => now()->addMonth(),
'used_events' => 1,
'active' => true,
]);
$user = User::factory()
->unverified()
->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
]);
$event = Event::factory()->for($tenant)->create([
'status' => 'published',
'is_active' => true,
'date' => now()->addDays(7),
'name' => ['de' => 'Sommerfest', 'en' => 'Summer Party'],
]);
$task = Task::factory()->create([
'tenant_id' => $tenant->id,
'is_completed' => true,
]);
$event->tasks()->attach($task);
Photo::factory()->for($event)->create([
'tenant_id' => $tenant->id,
'created_at' => now()->subDay(),
]);
PackagePurchase::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 149.00,
'type' => 'reseller_subscription',
'provider' => 'paddle',
'purchased_at' => now()->subDay(),
]);
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('dashboard')
->has('metrics', fn (AssertableInertia $metrics) => $metrics
->where('active_events', 1)
->where('total_events', 1)
->where('task_progress', 100)
->where('upcoming_events', 1)
->where('new_photos', 1)
->where('credit_balance', 4)
->etc()
)
->where('tenant.activePackage.name', 'Premium Paket')
->where('tenant.activePackage.remainingEvents', 9)
->has('upcomingEvents', 1)
->has('recentPurchases', 1)
->where('emailVerification.mustVerify', true)
->where('emailVerification.verified', false)
);
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace Tests\Feature\OAuth;
use App\Models\OAuthClient;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class AuthorizeTest extends TestCase
{
use RefreshDatabase;
public function test_authorize_redirects_guests_to_login(): void
{
$tenant = Tenant::factory()->create();
$client = $this->createClientForTenant($tenant);
$query = $this->buildAuthorizeQuery($client);
$fullUrl = url('/api/v1/oauth/authorize?'.http_build_query($query));
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertNotNull($location);
$this->assertStringStartsWith(route('tenant.admin.login'), $location);
$parsed = parse_url($location);
$actualQuery = [];
parse_str($parsed['query'] ?? '', $actualQuery);
$this->assertSame('login_required', $actualQuery['error'] ?? null);
$this->assertSame('Please sign in to continue.', $actualQuery['error_description'] ?? null);
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
$this->assertIntendedUrlMatches($query);
}
public function test_authorize_returns_json_payload_for_ajax_guests(): void
{
$tenant = Tenant::factory()->create();
$client = $this->createClientForTenant($tenant);
$query = $this->buildAuthorizeQuery($client);
$response = $this->withHeaders(['Accept' => 'application/json'])
->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertStatus(401)
->assertJson([
'error' => 'login_required',
'error_description' => 'Please sign in to continue.',
]);
$this->assertIntendedUrlMatches($query);
}
public function test_authorize_rejects_when_user_cannot_access_client_tenant(): void
{
$homeTenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $homeTenant->id,
'role' => 'tenant_admin',
]);
$client = $this->createClientForTenant($otherTenant);
$this->actingAs($user);
$query = $this->buildAuthorizeQuery($client);
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertNotNull($location);
$parsed = parse_url($location);
$actualQuery = [];
parse_str($parsed['query'] ?? '', $actualQuery);
$this->assertSame('tenant_mismatch', $actualQuery['error'] ?? null);
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
}
public function test_authorize_redirects_with_error_when_client_unknown(): void
{
$tenant = Tenant::factory()->create();
$this->actingAs(User::factory()->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
]));
$query = $this->buildAuthorizeQuery(new OAuthClient([
'client_id' => 'missing-client',
'redirect_uris' => ['http://localhost/callback'],
'scopes' => ['tenant:read', 'tenant:write'],
]));
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertNotNull($location);
$parsed = parse_url($location);
$actualQuery = [];
parse_str($parsed['query'] ?? '', $actualQuery);
$this->assertSame('invalid_client', $actualQuery['error'] ?? null);
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
}
public function test_authorize_returns_json_error_for_tenant_mismatch_when_requested(): void
{
$homeTenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $homeTenant->id,
'role' => 'tenant_admin',
]);
$client = $this->createClientForTenant($otherTenant);
$this->actingAs($user);
$query = $this->buildAuthorizeQuery($client);
$response = $this->withHeaders(['Accept' => 'application/json'])
->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertStatus(403)
->assertJson([
'error' => 'tenant_mismatch',
]);
}
private function createClientForTenant(Tenant $tenant): OAuthClient
{
return OAuthClient::create([
'id' => (string) Str::uuid(),
'client_id' => 'tenant-admin-app-'.$tenant->id,
'tenant_id' => $tenant->id,
'client_secret' => null,
'redirect_uris' => ['http://localhost/callback'],
'scopes' => ['tenant:read', 'tenant:write'],
'is_active' => true,
]);
}
private function buildAuthorizeQuery(OAuthClient $client): array
{
return [
'client_id' => $client->client_id,
'redirect_uri' => 'http://localhost/callback',
'response_type' => 'code',
'scope' => 'tenant:read tenant:write',
'state' => Str::random(10),
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', Str::random(32), true)), '+/', '-_'), '='),
'code_challenge_method' => 'S256',
];
}
private function assertIntendedUrlMatches(array $expectedQuery): void
{
$intended = session('url.intended');
$this->assertNotNull($intended, 'Expected intended URL to be recorded in session.');
$parts = parse_url($intended);
$this->assertSame('/api/v1/oauth/authorize', $parts['path'] ?? null);
$actualQuery = [];
parse_str($parts['query'] ?? '', $actualQuery);
$this->assertEqualsCanonicalizing($expectedQuery, $actualQuery);
}
private function decodeReturnTo(?string $value): ?string
{
if ($value === null) {
return null;
}
$padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '=');
$normalized = strtr($padded, '-_', '+/');
return base64_decode($normalized) ?: null;
}
private function assertReturnToMatches(array $expectedQuery, ?string $encoded): void
{
$decoded = $this->decodeReturnTo($encoded);
$this->assertNotNull($decoded, 'Failed to decode return_to parameter.');
$parts = parse_url($decoded);
$this->assertSame('/api/v1/oauth/authorize', $parts['path'] ?? null);
$actualQuery = [];
parse_str($parts['query'] ?? '', $actualQuery);
$this->assertEqualsCanonicalizing($expectedQuery, $actualQuery);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature\Profile;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class ProfilePageTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_displays_user_and_package_information(): void
{
$tenant = Tenant::factory()->create([
'event_credits_balance' => 7,
'subscription_status' => 'active',
'subscription_expires_at' => now()->addMonths(3),
]);
$package = Package::factory()->reseller()->create([
'name_translations' => [
'de' => 'Business Paket',
'en' => 'Business Package',
],
]);
TenantPackage::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 199.00,
'purchased_at' => now()->subWeek(),
'expires_at' => now()->addMonths(3),
'used_events' => 1,
'active' => true,
]);
PackagePurchase::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 199.00,
'type' => 'reseller_subscription',
'provider' => 'paddle',
'purchased_at' => now()->subWeek(),
]);
$user = User::factory()->unverified()->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
'name' => 'Alex Beispiel',
'email' => 'alex@example.test',
'preferred_locale' => 'de',
]);
$this->actingAs($user);
$response = $this->get(route('profile.index'));
$response->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/Index')
->where('userData.email', 'alex@example.test')
->where('userData.mustVerifyEmail', true)
->where('tenant.activePackage.name', 'Business Paket')
->has('purchases', fn (AssertableInertia $purchases) => $purchases
->where('0.packageName', 'Business Paket')
->etc()
)
);
}
}

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature\Tenant;
use App\Models\EventType;
use App\Models\Package;
use Illuminate\Support\Carbon;
class EventCreditsTest extends TenantTestCase
@@ -12,6 +13,20 @@ class EventCreditsTest extends TenantTestCase
$this->tenant->update(['event_credits_balance' => 0]);
$eventType = EventType::factory()->create();
$package = Package::factory()->create([
'type' => 'endcustomer',
'price' => 0,
'gallery_days' => 30,
]);
$this->tenant->tenantPackages()->create([
'package_id' => $package->id,
'price' => $package->price,
'purchased_at' => now()->subDay(),
'expires_at' => now()->addMonth(),
'active' => true,
]);
$payload = [
'name' => 'Sample Event',
'description' => 'Test description',
@@ -22,9 +37,8 @@ class EventCreditsTest extends TenantTestCase
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload);
$response->assertStatus(402)
->assertJson([
'error' => 'Insufficient event credits. Please purchase more credits.',
]);
->assertJsonPath('error.code', 'event_credits_exhausted')
->assertJsonPath('error.meta.balance', 0);
$this->tenant->update(['event_credits_balance' => 2]);
@@ -32,15 +46,14 @@ class EventCreditsTest extends TenantTestCase
$createResponse->assertStatus(201)
->assertJsonPath('message', 'Event created successfully')
->assertJsonPath('balance', 1);
->assertJsonPath('data.package.id', $package->id);
$this->tenant->refresh();
$this->assertSame(1, $this->tenant->event_credits_balance);
$createdEventId = $createResponse->json('data.id');
$this->assertDatabaseHas('event_credits_ledger', [
'tenant_id' => $this->tenant->id,
'delta' => -1,
'reason' => 'event_create',
$this->assertNotNull($createdEventId);
$this->assertDatabaseHas('event_packages', [
'event_id' => $createdEventId,
'package_id' => $package->id,
]);
}
}

View File

@@ -133,6 +133,8 @@ class EventListTest extends TenantTestCase
$matchingEvent = collect($response->json('data'))->firstWhere('id', $event->id);
$this->assertNotNull($matchingEvent, 'Event should still be returned even if package record is missing.');
$this->assertNull($matchingEvent['package'], 'Package payload should be null when relation cannot be resolved.');
$this->assertIsArray($matchingEvent['package'], 'Package payload should provide fallback data when relation is missing.');
$this->assertSame($package->id, $matchingEvent['package']['id']);
$this->assertSame((string) $package->price, $matchingEvent['package']['price']);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
class ProfileApiTest extends TenantTestCase
{
public function test_profile_endpoint_returns_current_user_details(): void
{
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/profile');
$response->assertOk();
$payload = $response->json('data');
$this->assertSame($this->tenantUser->id, $payload['id']);
$this->assertSame($this->tenantUser->email, $payload['email']);
$this->assertSame($this->tenantUser->name, $payload['name']);
$this->assertTrue($payload['email_verified']);
}
public function test_profile_update_allows_name_and_email_changes(): void
{
Notification::fake();
$newEmail = 'updated-'.$this->tenantUser->id.'@example.com';
$response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [
'name' => 'Updated Name',
'email' => $newEmail,
'preferred_locale' => 'en',
]);
$response->assertOk();
$payload = $response->json('data');
$this->assertSame('Updated Name', $payload['name']);
$this->assertSame($newEmail, $payload['email']);
$this->assertFalse($payload['email_verified']);
$this->assertSame('en', $payload['preferred_locale']);
$this->assertDatabaseHas(User::class, [
'id' => $this->tenantUser->id,
'name' => 'Updated Name',
'email' => $newEmail,
'preferred_locale' => 'en',
]);
Notification::assertSentToTimes($this->tenantUser->fresh(), \Illuminate\Auth\Notifications\VerifyEmail::class, 1);
}
public function test_profile_update_requires_current_password_for_password_change(): void
{
$response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [
'name' => $this->tenantUser->name,
'email' => $this->tenantUser->email,
'current_password' => 'wrong-password',
'password' => 'new-secure-password',
'password_confirmation' => 'new-secure-password',
]);
$response->assertStatus(422);
$response->assertJson([
'error' => [
'code' => 'profile.invalid_current_password',
],
]);
}
public function test_profile_update_allows_password_change_with_correct_current_password(): void
{
$newPassword = 'NewStrongPassword123!';
$response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [
'name' => $this->tenantUser->name,
'email' => $this->tenantUser->email,
'current_password' => 'password',
'password' => $newPassword,
'password_confirmation' => $newPassword,
]);
$response->assertOk();
$this->tenantUser->refresh();
$this->assertTrue(Hash::check($newPassword, $this->tenantUser->password));
}
}

View File

@@ -5,16 +5,16 @@ namespace Tests\Feature\Tenant;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SettingsApiTest extends TenantTestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected string $token;
protected function setUp(): void
@@ -165,7 +165,8 @@ class SettingsApiTest extends TenantTestCase
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain');
$response->assertStatus(400)
->assertJson(['error' => 'Domain ist erforderlich.']);
->assertJsonPath('error.code', 'domain_missing')
->assertJsonPath('error.message', 'Bitte gib eine Domain an.');
}
#[Test]
@@ -188,5 +189,3 @@ class SettingsApiTest extends TenantTestCase
->assertJsonMissing(['#FF0000']); // Other tenant's color
}
}

View File

@@ -8,16 +8,16 @@ use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TaskApiTest extends TenantTestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected string $token;
protected function setUp(): void
@@ -88,8 +88,8 @@ class TaskApiTest extends TenantTestCase
->assertJsonPath('data.tenant_id', $this->tenant->id);
$this->assertDatabaseHas('tasks', [
'title' => 'Test Task',
'tenant_id' => $this->tenant->id,
'title->de' => 'Test Task',
]);
}
@@ -108,7 +108,10 @@ class TaskApiTest extends TenantTestCase
{
$task = Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Viewable Task',
'title' => [
'de' => 'Viewable Task',
'en' => 'Viewable Task',
],
'priority' => 'medium',
]);
@@ -140,7 +143,10 @@ class TaskApiTest extends TenantTestCase
{
$task = Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Old Title',
'title' => [
'de' => 'Old Title',
'en' => 'Old Title',
],
'priority' => 'low',
]);
@@ -159,7 +165,7 @@ class TaskApiTest extends TenantTestCase
$this->assertDatabaseHas('tasks', [
'id' => $task->id,
'title' => 'Updated Title',
'title->de' => 'Updated Title',
'priority' => 'urgent',
]);
}
@@ -190,7 +196,6 @@ class TaskApiTest extends TenantTestCase
]);
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'event_type_id' => 1,
]);
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
@@ -214,7 +219,6 @@ class TaskApiTest extends TenantTestCase
]);
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'event_type_id' => 1,
]);
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
@@ -233,7 +237,6 @@ class TaskApiTest extends TenantTestCase
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'event_type_id' => 1,
]);
$eventTasks = Task::factory(2)->create([
'tenant_id' => $this->tenant->id,
@@ -284,18 +287,18 @@ class TaskApiTest extends TenantTestCase
{
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'First Task',
'priority' => 'medium'
'title' => ['de' => 'First Task', 'en' => 'First Task'],
'priority' => 'medium',
]);
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Search Test',
'priority' => 'medium'
'title' => ['de' => 'Search Test', 'en' => 'Search Test'],
'priority' => 'medium',
]);
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Another Task',
'priority' => 'medium'
'title' => ['de' => 'Another Task', 'en' => 'Another Task'],
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
@@ -306,7 +309,3 @@ class TaskApiTest extends TenantTestCase
->assertJsonPath('data.0.title', 'Search Test');
}
}

View File

@@ -14,9 +14,13 @@ abstract class TenantTestCase extends TestCase
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected OAuthClient $oauthClient;
protected string $token;
protected ?string $refreshToken = null;
protected function setUp(): void
@@ -75,6 +79,8 @@ abstract class TenantTestCase extends TestCase
protected function issueTokens(OAuthClient $client, array $scopes = ['tenant:read', 'tenant:write']): array
{
$this->actingAs($this->tenantUser);
$codeVerifier = 'tenant-code-verifier-'.Str::random(32);
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
$state = Str::random(10);
@@ -114,4 +120,3 @@ abstract class TenantTestCase extends TestCase
];
}
}