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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user