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

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

View File

@@ -8,8 +8,8 @@ use App\Models\PurchaseHistory;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ExportBulkAction;
use Filament\Actions\ViewAction;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
@@ -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([

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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