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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,45 +2,73 @@
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\Auth;
use Inertia\Response;
class ProfileController extends Controller
{
public function index()
public function index(Request $request): Response
{
$user = Auth::user()->load('purchases.packages');
$user = $request->user()
->load(['tenant' => function ($query) {
$query->with([
'purchases' => fn ($purchases) => $purchases
->with('package')
->latest('purchased_at')
->limit(10),
'tenantPackages' => fn ($packages) => $packages
->with('package')
->orderByDesc('active')
->orderByDesc('purchased_at'),
]);
}]);
$tenant = $user->tenant;
$activePackage = $tenant?->tenantPackages
?->first(fn ($package) => (bool) $package->active);
$purchases = $tenant?->purchases
?->map(fn ($purchase) => [
'id' => $purchase->id,
'packageName' => $purchase->package?->getNameForLocale(app()->getLocale())
?? $purchase->package?->name
?? __('Unknown package'),
'price' => $purchase->price !== null ? (float) $purchase->price : null,
'purchasedAt' => optional($purchase->purchased_at)->toIso8601String(),
'type' => $purchase->type,
'provider' => $purchase->provider,
])
->values()
->all();
return Inertia::render('Profile/Index', [
'user' => $user,
'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,
]);
}
}
}

View File

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