die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.
This commit is contained in:
@@ -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;
|
||||
@@ -94,7 +94,7 @@ class PurchaseHistoryResource extends Resource
|
||||
->label(__('admin.purchase_history.fields.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(function ($state): string {
|
||||
$key = 'admin.purchase_history.platforms.' . (string) $state;
|
||||
$key = 'admin.purchase_history.platforms.'.(string) $state;
|
||||
$translated = __($key);
|
||||
|
||||
return $translated === $key ? Str::headline((string) $state) : $translated;
|
||||
@@ -148,7 +148,7 @@ class PurchaseHistoryResource extends Resource
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
ViewAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
134
app/Http/Controllers/Api/Tenant/ProfileController.php
Normal file
134
app/Http/Controllers/Api/Tenant/ProfileController.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
136
app/Http/Controllers/DashboardController.php
Normal file
136
app/Http/Controllers/DashboardController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
105
app/Http/Controllers/TenantAdminGoogleController.php
Normal file
105
app/Http/Controllers/TenantAdminGoogleController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/Tenant/ProfileUpdateRequest.php
Normal file
53
app/Http/Requests/Tenant/ProfileUpdateRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
75
app/Services/Tenant/DashboardSummaryService.php
Normal file
75
app/Services/Tenant/DashboardSummaryService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Support/TenantAuth.php
Normal file
54
app/Support/TenantAuth.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,79 +31,134 @@ class DemoAchievementsSeeder extends Seeder
|
||||
|
||||
if ($sourceFiles->isEmpty()) {
|
||||
$this->command?->warn('No demo photo files found – skipping DemoAchievementsSeeder');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$blueprints = [
|
||||
['guest' => 'Anna Mueller', 'photos' => 6, 'likes' => [12, 8, 5, 4, 2, 1], 'withTasks' => true],
|
||||
['guest' => 'Max Schmidt', 'photos' => 4, 'likes' => [9, 7, 4, 2], 'withTasks' => true],
|
||||
['guest' => 'Lisa Weber', 'photos' => 2, 'likes' => [3, 1], 'withTasks' => false],
|
||||
['guest' => 'Tom Fischer', 'photos' => 1, 'likes' => [14], 'withTasks' => true],
|
||||
['guest' => 'Team Brautparty', 'photos' => 5, 'likes' => [5, 4, 3, 3, 2], 'withTasks' => true],
|
||||
];
|
||||
$emotions = Emotion::pluck('id')->all();
|
||||
if ($emotions === []) {
|
||||
$this->command?->warn('No emotions available – skipping DemoAchievementsSeeder');
|
||||
|
||||
$eventDate = $event->date ? CarbonImmutable::parse($event->date) : CarbonImmutable::now();
|
||||
$baseDir = "events/{$event->id}/achievements";
|
||||
Storage::disk('public')->makeDirectory($baseDir);
|
||||
Storage::disk('public')->makeDirectory("{$baseDir}/thumbs");
|
||||
|
||||
$photoIndex = 0;
|
||||
|
||||
foreach ($blueprints as $groupIndex => $blueprint) {
|
||||
for ($i = 0; $i < $blueprint['photos']; $i++) {
|
||||
$source = $sourceFiles[$photoIndex % $sourceFiles->count()];
|
||||
$photoIndex++;
|
||||
|
||||
$filename = Str::slug($blueprint['guest'] . '-' . $groupIndex . '-' . $i) . '.jpg';
|
||||
$destPath = "{$baseDir}/{$filename}";
|
||||
if (! Storage::disk('public')->exists($destPath)) {
|
||||
Storage::disk('public')->copy($source, $destPath);
|
||||
}
|
||||
|
||||
$thumbSource = str_replace('photos/', 'thumbnails/', $source);
|
||||
$thumbDest = "{$baseDir}/thumbs/{$filename}";
|
||||
if (Storage::disk('public')->exists($thumbSource)) {
|
||||
Storage::disk('public')->copy($thumbSource, $thumbDest);
|
||||
} else {
|
||||
Storage::disk('public')->copy($source, $thumbDest);
|
||||
}
|
||||
|
||||
$taskId = $blueprint['withTasks'] ? $tasks[($groupIndex + $i) % count($tasks)] : null;
|
||||
$emotionId = $emotions[($groupIndex * 3 + $i) % count($emotions)];
|
||||
$createdAt = $eventDate->addHours($groupIndex * 2 + $i);
|
||||
$likes = $blueprint['likes'][$i] ?? 0;
|
||||
|
||||
$photo = Photo::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'guest_name' => $blueprint['guest'],
|
||||
'file_path' => $destPath,
|
||||
],
|
||||
[
|
||||
'task_id' => $taskId,
|
||||
'emotion_id' => $emotionId,
|
||||
'thumbnail_path' => $thumbDest,
|
||||
'likes_count' => $likes,
|
||||
'is_featured' => $i === 0,
|
||||
'metadata' => ['demo' => true],
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]
|
||||
);
|
||||
|
||||
PhotoLike::where('photo_id', $photo->id)->delete();
|
||||
for ($like = 0; $like < min($likes, 15); $like++) {
|
||||
PhotoLike::create([
|
||||
'photo_id' => $photo->id,
|
||||
'guest_name' => 'Guest_' . Str::random(6),
|
||||
'ip_address' => '10.0.' . rand(0, 254) . '.' . rand(0, 254),
|
||||
'created_at' => $createdAt->addMinutes($like * 3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$this->command?->info('Demo achievements seeded.');
|
||||
$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 ($scenario['blueprints'] as $groupIndex => $blueprint) {
|
||||
for ($i = 0; $i < $blueprint['photos']; $i++) {
|
||||
$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($sourcePath, $destPath);
|
||||
}
|
||||
|
||||
$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($sourcePath, $thumbDest);
|
||||
}
|
||||
|
||||
$taskId = null;
|
||||
if (! empty($taskIds) && ($blueprint['withTasks'] ?? false)) {
|
||||
$taskId = $taskIds[($groupIndex + $i) % count($taskIds)];
|
||||
}
|
||||
|
||||
$emotionId = $emotions[($groupIndex * 3 + $i) % count($emotions)];
|
||||
$createdAt = $eventDate->addHours($groupIndex * 2 + $i);
|
||||
$likes = $blueprint['likes'][$i] ?? 0;
|
||||
|
||||
$photo = Photo::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'guest_name' => $blueprint['guest'],
|
||||
'file_path' => $destPath,
|
||||
],
|
||||
[
|
||||
'task_id' => $taskId,
|
||||
'emotion_id' => $emotionId,
|
||||
'thumbnail_path' => $thumbDest,
|
||||
'likes_count' => $likes,
|
||||
'is_featured' => $i === 0,
|
||||
'metadata' => ['demo' => true, 'achievement' => true],
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]
|
||||
);
|
||||
|
||||
PhotoLike::where('photo_id', $photo->id)->delete();
|
||||
for ($like = 0; $like < min($likes, 15); $like++) {
|
||||
PhotoLike::create([
|
||||
'photo_id' => $photo->id,
|
||||
'guest_name' => 'Guest_'.Str::random(6),
|
||||
'ip_address' => '10.0.'.rand(0, 254).'.'.rand(0, 254),
|
||||
'created_at' => $createdAt->addMinutes($like * 3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->command?->info(sprintf('Demo achievements seeded for %s.', $event->slug));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,79 +7,216 @@ 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,
|
||||
'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,
|
||||
'status' => 'published',
|
||||
'is_active' => true,
|
||||
'settings' => json_encode([]),
|
||||
'default_locale' => 'de',
|
||||
]);
|
||||
|
||||
if ($event->joinTokens()->count() === 0) {
|
||||
/** @var EventJoinTokenService $service */
|
||||
$service = app(EventJoinTokenService::class);
|
||||
$service->createToken($event, [
|
||||
'label' => 'Demo QR',
|
||||
]);
|
||||
}
|
||||
$weddingType = EventType::where('slug', 'wedding')->first();
|
||||
$corporateType = EventType::where('slug', 'corporate')->first();
|
||||
|
||||
$package = Package::where('slug', 'standard')->first();
|
||||
if (! $package) {
|
||||
$package = Package::where('type', 'endcustomer')->orderBy('price')->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();
|
||||
|
||||
if ($package) {
|
||||
$eventPackageData = [
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now()->subDays(7),
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
EventPackage::updateOrCreate(
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
$events = [
|
||||
[
|
||||
'slug' => 'demo-wedding-2025',
|
||||
'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'],
|
||||
'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'],
|
||||
'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',
|
||||
],
|
||||
$eventPackageData
|
||||
],
|
||||
[
|
||||
'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' => [
|
||||
'branding' => $config['branding'],
|
||||
],
|
||||
'default_locale' => 'de',
|
||||
]
|
||||
);
|
||||
|
||||
PackagePurchase::query()
|
||||
->where('tenant_id', $demoTenant->id)
|
||||
->where('package_id', $package->id)
|
||||
->where('provider_id', 'demo-seed')
|
||||
->update([
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureJoinToken(Event $event, string $label): void
|
||||
{
|
||||
if ($event->joinTokens()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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' => $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'] = $purchasedAt->copy()->addDays($package->gallery_days ?? 30);
|
||||
}
|
||||
|
||||
EventPackage::updateOrCreate(
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
$eventPackageData
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
if (!$demoEvent || !$demoTenant) {
|
||||
$this->command->info('Demo event or tenant not found, skipping DemoPhotosSeeder');
|
||||
$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');
|
||||
|
||||
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 = [
|
||||
'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'
|
||||
$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',
|
||||
],
|
||||
'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);
|
||||
|
||||
$seededCount = 0;
|
||||
foreach ($photoFiles as $file) {
|
||||
$filename = $file->getFilename();
|
||||
if (!str_ends_with($filename, '.jpg')) {
|
||||
foreach ($events as $config) {
|
||||
/** @var Event|null $event */
|
||||
$event = $config['model'];
|
||||
|
||||
if (! $event) {
|
||||
$this->command->warn('Demo event missing, skipping photo seeding.');
|
||||
|
||||
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)),
|
||||
'guest_name' => 'GuestLike_'.Str::random(6),
|
||||
'ip_address' => '10.0.'.rand(0, 254).'.'.rand(1, 254),
|
||||
'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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
if (!$demoTenant) {
|
||||
$demoTenant = Tenant::where('slug', 'demo-tenant')->first();
|
||||
if (! $demoTenant) {
|
||||
$this->command->info('Demo tenant not found, skipping EventTasksSeeder');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,14 +200,17 @@ class EventTasksSeeder extends Seeder
|
||||
];
|
||||
|
||||
// Difficulty rotation
|
||||
$difficulties = ['easy','easy','medium','easy','medium','hard'];
|
||||
$difficulties = ['easy', 'easy', 'medium', 'easy', 'medium', 'hard'];
|
||||
|
||||
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,
|
||||
@@ -233,7 +242,7 @@ class EventTasksSeeder extends Seeder
|
||||
$i = 0;
|
||||
while ($created < 20 && $i < count($list)) {
|
||||
[$deTitle, $deDesc, $enTitle, $enDesc] = $list[$i];
|
||||
$suffix = ' #' . ($created + 1);
|
||||
$suffix = ' #'.($created + 1);
|
||||
Task::create([
|
||||
'tenant_id' => $demoTenant->id,
|
||||
'emotion_id' => $emotion->id,
|
||||
@@ -245,9 +254,9 @@ class EventTasksSeeder extends Seeder
|
||||
'sort_order' => $order++,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$created++; $i++;
|
||||
$created++;
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
|
||||
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;
|
||||
$weddingType = EventType::where('slug', 'wedding')->first();
|
||||
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();
|
||||
$by = fn (string $en) => Emotion::where('name->en', $en)->first();
|
||||
|
||||
$emLove = $by('Love');
|
||||
$emJoy = $by('Joy');
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,17 +86,34 @@ 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 } = {}) => {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
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);
|
||||
setStatus('unauthenticated');
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
51
resources/js/admin/i18n/locales/de/settings.json
Normal file
51
resources/js/admin/i18n/locales/de/settings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 couldn’t 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 don’t 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 couldn’t link this Google account to a tenant admin. Please sign in with Fotospiel credentials."
|
||||
},
|
||||
"return_hint": "After signing in you’ll be brought back automatically.",
|
||||
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
|
||||
"appearance_label": "Appearance"
|
||||
}
|
||||
|
||||
51
resources/js/admin/i18n/locales/en/settings.json
Normal file
51
resources/js/admin/i18n/locales/en/settings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
172
resources/js/admin/lib/returnTo.ts
Normal file
172
resources/js/admin/lib/returnTo.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,204 +1,223 @@
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{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>{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="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="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={() => {
|
||||
if (shouldOpenAccountLogin) {
|
||||
window.location.href = marketingLoginUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
<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>
|
||||
storeLastDestination(redirectTarget);
|
||||
login(redirectTarget);
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('login.loading', 'Signing you in …')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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-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}
|
||||
</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">
|
||||
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
|
||||
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<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)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('login.loading', 'Signing you in …')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
<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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
55
resources/js/admin/pages/LoginStartPage.tsx
Normal file
55
resources/js/admin/pages/LoginStartPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
421
resources/js/admin/pages/ProfilePage.tsx
Normal file
421
resources/js/admin/pages/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<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">{brandLabel}</span>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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;
|
||||
@@ -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 & 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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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="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="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>
|
||||
</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 & 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>
|
||||
<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" />
|
||||
</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>
|
||||
);
|
||||
|
||||
1
resources/js/types/index.d.ts
vendored
1
resources/js/types/index.d.ts
vendored
@@ -28,6 +28,7 @@ export interface SharedData {
|
||||
auth: Auth;
|
||||
sidebarOpen: boolean;
|
||||
supportedLocales?: string[];
|
||||
locale?: string;
|
||||
security?: {
|
||||
csp?: {
|
||||
scriptNonce?: string;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -47,4 +55,4 @@
|
||||
"notice": "Please verify your email address.",
|
||||
"resend": "Resend email"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([
|
||||
|
||||
102
tests/Feature/Auth/TenantAdminGoogleControllerTest.php
Normal file
102
tests/Feature/Auth/TenantAdminGoogleControllerTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
104
tests/Feature/Dashboard/DashboardPageTest.php
Normal file
104
tests/Feature/Dashboard/DashboardPageTest.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
204
tests/Feature/OAuth/AuthorizeTest.php
Normal file
204
tests/Feature/OAuth/AuthorizeTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
tests/Feature/Profile/ProfilePageTest.php
Normal file
76
tests/Feature/Profile/ProfilePageTest.php
Normal 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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
89
tests/Feature/Tenant/ProfileApiTest.php
Normal file
89
tests/Feature/Tenant/ProfileApiTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -37,9 +37,9 @@ class SettingsApiTest extends TenantTestCase
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/settings');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Settings erfolgreich abgerufen.'])
|
||||
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
|
||||
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
|
||||
->assertJson(['message' => 'Settings erfolgreich abgerufen.'])
|
||||
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
|
||||
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -64,10 +64,10 @@ class SettingsApiTest extends TenantTestCase
|
||||
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $settingsData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Settings erfolgreich aktualisiert.'])
|
||||
->assertJsonPath('data.settings.branding.primary_color', '#FF6B6B')
|
||||
->assertJsonPath('data.settings.features.photo_likes_enabled', false)
|
||||
->assertJsonPath('data.settings.custom_domain', 'custom.example.com');
|
||||
->assertJson(['message' => 'Settings erfolgreich aktualisiert.'])
|
||||
->assertJsonPath('data.settings.branding.primary_color', '#FF6B6B')
|
||||
->assertJsonPath('data.settings.features.photo_likes_enabled', false)
|
||||
->assertJsonPath('data.settings.custom_domain', 'custom.example.com');
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'id' => $this->tenant->id,
|
||||
@@ -89,9 +89,9 @@ class SettingsApiTest extends TenantTestCase
|
||||
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $invalidData);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'settings.branding.primary_color',
|
||||
]);
|
||||
->assertJsonValidationErrors([
|
||||
'settings.branding.primary_color',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -100,9 +100,9 @@ class SettingsApiTest extends TenantTestCase
|
||||
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/reset');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Settings auf Standardwerte zurueckgesetzt.'])
|
||||
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
|
||||
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
|
||||
->assertJson(['message' => 'Settings auf Standardwerte zurueckgesetzt.'])
|
||||
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
|
||||
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'id' => $this->tenant->id,
|
||||
@@ -135,8 +135,8 @@ class SettingsApiTest extends TenantTestCase
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['available' => true])
|
||||
->assertJson(['message' => 'Domain ist verfuegbar.']);
|
||||
->assertJson(['available' => true])
|
||||
->assertJson(['message' => 'Domain ist verfuegbar.']);
|
||||
|
||||
// Invalid domain format
|
||||
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [
|
||||
@@ -144,19 +144,19 @@ class SettingsApiTest extends TenantTestCase
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['available' => false])
|
||||
->assertJson(['message' => 'Ungueltiges Domain-Format.']);
|
||||
->assertJson(['available' => false])
|
||||
->assertJson(['message' => 'Ungueltiges Domain-Format.']);
|
||||
|
||||
// Taken domain (create another tenant with same domain)
|
||||
$otherTenant = Tenant::factory()->create(['custom_domain' => 'taken.example.com']);
|
||||
|
||||
|
||||
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [
|
||||
'domain' => 'taken.example.com',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['available' => false])
|
||||
->assertJson(['message' => 'Domain ist bereits vergeben.']);
|
||||
->assertJson(['available' => false])
|
||||
->assertJson(['message' => 'Domain ist bereits vergeben.']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -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]
|
||||
@@ -178,15 +179,13 @@ class SettingsApiTest extends TenantTestCase
|
||||
'tenant_id' => $otherTenant->id,
|
||||
'role' => 'admin',
|
||||
]);
|
||||
$otherToken = 'mock-jwt-token-' . $otherTenant->id . '-' . time();
|
||||
$otherToken = 'mock-jwt-token-'.$otherTenant->id.'-'.time();
|
||||
|
||||
// This tenant's user should not see other tenant's settings
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/settings');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') // Default for this tenant
|
||||
->assertJsonMissing(['#FF0000']); // Other tenant's color
|
||||
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') // Default for this tenant
|
||||
->assertJsonMissing(['#FF0000']); // Other tenant's color
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -45,7 +45,7 @@ class TaskApiTest extends TenantTestCase
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/tasks');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(3, 'data');
|
||||
->assertJsonCount(3, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -62,11 +62,11 @@ class TaskApiTest extends TenantTestCase
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->getJson('/api/v1/tenant/tasks');
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->getJson('/api/v1/tenant/tasks');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(3, 'data');
|
||||
->assertJsonCount(3, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -79,28 +79,28 @@ class TaskApiTest extends TenantTestCase
|
||||
'due_date' => now()->addDays(7)->toISOString(),
|
||||
];
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->postJson('/api/v1/tenant/tasks', $taskData);
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->postJson('/api/v1/tenant/tasks', $taskData);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJson(['message' => 'Task erfolgreich erstellt.'])
|
||||
->assertJsonPath('data.title', 'Test Task')
|
||||
->assertJsonPath('data.tenant_id', $this->tenant->id);
|
||||
->assertJson(['message' => 'Task erfolgreich erstellt.'])
|
||||
->assertJsonPath('data.title', 'Test Task')
|
||||
->assertJsonPath('data.tenant_id', $this->tenant->id);
|
||||
|
||||
$this->assertDatabaseHas('tasks', [
|
||||
'title' => 'Test Task',
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'title->de' => 'Test Task',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function task_creation_requires_valid_data()
|
||||
{
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->postJson('/api/v1/tenant/tasks', []);
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->postJson('/api/v1/tenant/tasks', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['title']);
|
||||
->assertJsonValidationErrors(['title']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -108,15 +108,18 @@ 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',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->getJson("/api/v1/tenant/tasks/{$task->id}");
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->getJson("/api/v1/tenant/tasks/{$task->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['title' => 'Viewable Task']);
|
||||
->assertJson(['title' => 'Viewable Task']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -129,8 +132,8 @@ class TaskApiTest extends TenantTestCase
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->getJson("/api/v1/tenant/tasks/{$otherTask->id}");
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->getJson("/api/v1/tenant/tasks/{$otherTask->id}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -149,17 +155,17 @@ class TaskApiTest extends TenantTestCase
|
||||
'priority' => 'urgent',
|
||||
];
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->patchJson("/api/v1/tenant/tasks/{$task->id}", $updateData);
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->patchJson("/api/v1/tenant/tasks/{$task->id}", $updateData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Task erfolgreich aktualisiert.'])
|
||||
->assertJsonPath('data.title', 'Updated Title')
|
||||
->assertJsonPath('data.priority', 'urgent');
|
||||
->assertJson(['message' => 'Task erfolgreich aktualisiert.'])
|
||||
->assertJsonPath('data.title', 'Updated Title')
|
||||
->assertJsonPath('data.priority', 'urgent');
|
||||
|
||||
$this->assertDatabaseHas('tasks', [
|
||||
'id' => $task->id,
|
||||
'title' => 'Updated Title',
|
||||
'title->de' => 'Updated Title',
|
||||
'priority' => 'urgent',
|
||||
]);
|
||||
}
|
||||
@@ -172,11 +178,11 @@ class TaskApiTest extends TenantTestCase
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->deleteJson("/api/v1/tenant/tasks/{$task->id}");
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->deleteJson("/api/v1/tenant/tasks/{$task->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Task erfolgreich gelöscht.']);
|
||||
->assertJson(['message' => 'Task erfolgreich gelöscht.']);
|
||||
|
||||
$this->assertSoftDeleted('tasks', ['id' => $task->id]);
|
||||
}
|
||||
@@ -190,14 +196,13 @@ class TaskApiTest extends TenantTestCase
|
||||
]);
|
||||
$event = Event::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'event_type_id' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->postJson("/api/v1/tenant/tasks/{$task->id}/assign-event/{$event->id}");
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->postJson("/api/v1/tenant/tasks/{$task->id}/assign-event/{$event->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Task erfolgreich dem Event zugewiesen.']);
|
||||
->assertJson(['message' => 'Task erfolgreich dem Event zugewiesen.']);
|
||||
|
||||
$this->assertDatabaseHas('event_task', [
|
||||
'task_id' => $task->id,
|
||||
@@ -214,16 +219,15 @@ class TaskApiTest extends TenantTestCase
|
||||
]);
|
||||
$event = Event::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'event_type_id' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->postJson("/api/v1/tenant/tasks/bulk-assign-event/{$event->id}", [
|
||||
'task_ids' => $tasks->pluck('id')->toArray(),
|
||||
]);
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->postJson("/api/v1/tenant/tasks/bulk-assign-event/{$event->id}", [
|
||||
'task_ids' => $tasks->pluck('id')->toArray(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => '3 Tasks dem Event zugewiesen.']);
|
||||
->assertJson(['message' => '3 Tasks dem Event zugewiesen.']);
|
||||
|
||||
$this->assertEquals(3, $event->tasks()->count());
|
||||
}
|
||||
@@ -233,24 +237,23 @@ 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,
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
$eventTasks->each(fn($task) => $task->assignedEvents()->attach($event->id));
|
||||
$eventTasks->each(fn ($task) => $task->assignedEvents()->attach($event->id));
|
||||
|
||||
Task::factory(3)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'priority' => 'medium',
|
||||
]); // Other tasks
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->getJson("/api/v1/tenant/tasks/event/{$event->id}");
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->getJson("/api/v1/tenant/tasks/event/{$event->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(2, 'data');
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -272,11 +275,11 @@ class TaskApiTest extends TenantTestCase
|
||||
'priority' => 'medium',
|
||||
]); // Other tasks
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
|
||||
->getJson("/api/v1/tenant/tasks?collection_id={$collection->id}");
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->getJson("/api/v1/tenant/tasks?collection_id={$collection->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(2, 'data');
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -284,29 +287,25 @@ 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])
|
||||
->getJson('/api/v1/tenant/tasks?search=Search');
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
||||
->getJson('/api/v1/tenant/tasks?search=Search');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.title', 'Search Test');
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.title', 'Search Test');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user