stage 1 of oauth removal, switch to sanctum pat tokens
This commit is contained in:
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OAuthListKeysCommand extends Command
|
||||
{
|
||||
protected $signature = 'oauth:list-keys {--json : Output as JSON for scripting}';
|
||||
|
||||
protected $description = 'List available JWT signing key directories and their status.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
|
||||
|
||||
if (! File::exists($storage)) {
|
||||
$this->error("Key store path does not exist: {$storage}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$directories = collect(File::directories($storage))
|
||||
->filter(fn ($path) => Str::lower(basename($path)) !== 'archive')
|
||||
->values()
|
||||
->map(function (string $path) use ($currentKid) {
|
||||
$kid = basename($path);
|
||||
$publicKey = $path.DIRECTORY_SEPARATOR.'public.key';
|
||||
$privateKey = $path.DIRECTORY_SEPARATOR.'private.key';
|
||||
|
||||
return [
|
||||
'kid' => $kid,
|
||||
'status' => $kid === $currentKid ? 'current' : 'legacy',
|
||||
'public' => File::exists($publicKey),
|
||||
'private' => File::exists($privateKey),
|
||||
'updated_at' => File::exists($path) ? date('c', File::lastModified($path)) : null,
|
||||
'path' => $path,
|
||||
];
|
||||
})
|
||||
->sortBy(fn ($entry) => ($entry['status'] === 'current' ? '0-' : '1-').$entry['kid'])
|
||||
->values();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line($directories->toJson(JSON_PRETTY_PRINT));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($directories->isEmpty()) {
|
||||
$this->warn('No signing key directories found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['KID', 'Status', 'Public.key', 'Private.key', 'Updated At', 'Path'],
|
||||
$directories->map(fn ($entry) => [
|
||||
$entry['kid'],
|
||||
$entry['status'],
|
||||
$entry['public'] ? 'yes' : 'no',
|
||||
$entry['private'] ? 'yes' : 'no',
|
||||
$entry['updated_at'] ?? 'n/a',
|
||||
$entry['path'],
|
||||
])
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OAuthPruneKeysCommand extends Command
|
||||
{
|
||||
protected $signature = 'oauth:prune-keys
|
||||
{--days=90 : Prune keys whose directories were last modified before this many days ago}
|
||||
{--dry-run : Show which keys would be removed without deleting}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Remove legacy JWT signing keys older than the configured threshold.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
|
||||
|
||||
if (! File::exists($storage)) {
|
||||
$this->error("Key store path does not exist: {$storage}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$days = (int) $this->option('days');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$candidates = collect(File::directories($storage))
|
||||
->reject(fn ($path) => Str::lower(basename($path)) === 'archive')
|
||||
->filter(function (string $path) use ($currentKid, $cutoff) {
|
||||
$kid = basename($path);
|
||||
if ($kid === $currentKid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lastModified = File::lastModified($path);
|
||||
|
||||
return $lastModified !== false && $cutoff->greaterThan(\Carbon\Carbon::createFromTimestamp($lastModified));
|
||||
})
|
||||
->values();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info("No legacy key directories older than {$days} days were found.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['KID', 'Last Modified', 'Path'],
|
||||
$candidates->map(fn ($path) => [
|
||||
basename($path),
|
||||
date('c', File::lastModified($path)),
|
||||
$path,
|
||||
])
|
||||
);
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('Dry run complete. No keys were removed.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm('Remove the listed legacy key directories?', false)) {
|
||||
$this->warn('Prune cancelled.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
|
||||
$this->info('Legacy key directories pruned.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class OAuthRotateKeysCommand extends Command
|
||||
{
|
||||
protected $signature = 'oauth:rotate-keys {--kid=} {--force : Do not prompt for confirmation}';
|
||||
|
||||
protected $description = 'Generate a new JWT signing key pair for tenant OAuth tokens.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
|
||||
$newKid = $this->option('kid') ?: 'kid-'.now()->format('YmdHis');
|
||||
|
||||
if (! $this->option('force') &&
|
||||
! $this->confirm("Rotate JWT keys? Current kid: {$currentKid}. New kid: {$newKid}", true)
|
||||
) {
|
||||
$this->info('Rotation cancelled.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists($storage);
|
||||
|
||||
$archiveDir = $this->archiveExistingKeys($storage, $currentKid);
|
||||
|
||||
$newDirectory = $storage.DIRECTORY_SEPARATOR.$newKid;
|
||||
if (File::exists($newDirectory)) {
|
||||
$this->error("Target directory already exists: {$newDirectory}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
File::makeDirectory($newDirectory, 0700, true);
|
||||
$this->generateKeyPair($newDirectory);
|
||||
|
||||
$this->info('New signing keys generated.');
|
||||
$this->line("Path: {$newDirectory}");
|
||||
|
||||
if ($archiveDir) {
|
||||
$this->line("Previous keys archived at: {$archiveDir}");
|
||||
$this->line('Existing key remains available for token verification until you prune it.');
|
||||
}
|
||||
|
||||
$this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}");
|
||||
$this->info('Run `php artisan oauth:list-keys` to verify active signing directories.');
|
||||
$this->info('Once legacy tokens expire, run `php artisan oauth:prune-keys` to remove retired keys.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function archiveExistingKeys(string $storage, string $kid): ?string
|
||||
{
|
||||
$existingDir = $storage.DIRECTORY_SEPARATOR.$kid;
|
||||
$legacyPublic = storage_path('app/public.key');
|
||||
$legacyPrivate = storage_path('app/private.key');
|
||||
|
||||
if (File::exists($existingDir)) {
|
||||
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis');
|
||||
File::ensureDirectoryExists(dirname($archiveDir));
|
||||
File::copyDirectory($existingDir, $archiveDir);
|
||||
return $archiveDir;
|
||||
}
|
||||
|
||||
if (File::exists($legacyPublic) || File::exists($legacyPrivate)) {
|
||||
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.'legacy-'.now()->format('YmdHis');
|
||||
File::ensureDirectoryExists($archiveDir);
|
||||
|
||||
if (File::exists($legacyPublic)) {
|
||||
File::copy($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
|
||||
}
|
||||
|
||||
if (File::exists($legacyPrivate)) {
|
||||
File::copy($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
|
||||
}
|
||||
|
||||
return $archiveDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function generateKeyPair(string $directory): void
|
||||
{
|
||||
$config = [
|
||||
'digest_alg' => OPENSSL_ALGO_SHA256,
|
||||
'private_key_bits' => 4096,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$resource = openssl_pkey_new($config);
|
||||
if (! $resource) {
|
||||
throw new \RuntimeException('Failed to generate key pair');
|
||||
}
|
||||
|
||||
openssl_pkey_export($resource, $privateKey);
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicKey = $details['key'] ?? null;
|
||||
|
||||
if (! $publicKey) {
|
||||
throw new \RuntimeException('Unable to extract public key');
|
||||
}
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600);
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||
}
|
||||
}
|
||||
@@ -50,12 +50,24 @@ class AuthenticatedSessionController extends Controller
|
||||
return Inertia::location(route('verification.notice'));
|
||||
}
|
||||
|
||||
$intended = $this->resolveIntended($request);
|
||||
if ($intended !== null) {
|
||||
$this->rememberTenantAdminTarget($request, $intended);
|
||||
|
||||
return Inertia::location($intended);
|
||||
}
|
||||
|
||||
$returnTo = $this->resolveReturnTo($request);
|
||||
if ($returnTo !== null) {
|
||||
$this->rememberTenantAdminTarget($request, $returnTo);
|
||||
|
||||
return Inertia::location($returnTo);
|
||||
}
|
||||
|
||||
return Inertia::location($this->defaultAdminPath());
|
||||
$default = $this->defaultAdminPath();
|
||||
$this->rememberTenantAdminTarget($request, $default);
|
||||
|
||||
return Inertia::location($default);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +91,29 @@ class AuthenticatedSessionController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->decodeReturnTo($encoded, $request);
|
||||
return $this->normalizeTenantAdminTarget(
|
||||
$this->decodeReturnTo($encoded, $request),
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveIntended(Request $request): ?string
|
||||
{
|
||||
$intended = $request->session()->pull('url.intended');
|
||||
|
||||
if (! is_string($intended)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($intended);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeTenantAdminTarget(
|
||||
$this->decodeReturnTo($trimmed, $request),
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
private function decodeReturnTo(string $value, Request $request): ?string
|
||||
@@ -113,12 +147,73 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
private function defaultAdminPath(): string
|
||||
{
|
||||
$base = rtrim(route('tenant.admin.app', absolute: false), '/');
|
||||
if ($base === '') {
|
||||
$base = '/event-admin';
|
||||
$user = Auth::user();
|
||||
|
||||
// Block users with 'user' role - redirect to package selection
|
||||
if ($user && $user->role === 'user') {
|
||||
return '/packages';
|
||||
}
|
||||
|
||||
return $base.'/events';
|
||||
// Super admins go to Filament superadmin panel
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
// Tenant admins go to their PWA dashboard
|
||||
if ($user && $user->role === 'tenant_admin') {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
// Fallback: redirect to packages (for users with no role)
|
||||
return '/packages';
|
||||
}
|
||||
|
||||
private function normalizeTenantAdminTarget(?string $target, Request $request): ?string
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user || $user->role !== 'tenant_admin') {
|
||||
return $target;
|
||||
}
|
||||
|
||||
if ($target === null || $target === '') {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
$parsed = parse_url($target);
|
||||
$path = $target;
|
||||
$hasScheme = false;
|
||||
|
||||
if ($parsed !== false) {
|
||||
$hasScheme = isset($parsed['scheme']);
|
||||
$host = $parsed['host'] ?? null;
|
||||
$scheme = $parsed['scheme'] ?? null;
|
||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
if (isset($parsed['path'])) {
|
||||
$path = $parsed['path'];
|
||||
if (isset($parsed['query'])) {
|
||||
$path .= '?'.$parsed['query'];
|
||||
}
|
||||
if (isset($parsed['fragment'])) {
|
||||
$path .= '#'.$parsed['fragment'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! str_starts_with($path, '/')) {
|
||||
$path = '/'.$path;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/event-admin') || str_starts_with($path, '/api/v1/oauth/authorize')) {
|
||||
return $hasScheme ? $target : $path;
|
||||
}
|
||||
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
private function decodeBase64Url(string $value): ?string
|
||||
@@ -137,4 +232,66 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user || $user->role !== 'tenant_admin') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($target) || $target === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeTenantAdminTarget($target, $request);
|
||||
|
||||
if (! is_string($normalized) || $normalized === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $this->extractTenantAdminPath($normalized);
|
||||
|
||||
if ($path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->session()->put('tenant_admin.return_to', $path);
|
||||
}
|
||||
|
||||
private function extractTenantAdminPath(string $target): ?string
|
||||
{
|
||||
$value = trim($target);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($value, '/event-admin')) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$parsed = parse_url($value);
|
||||
|
||||
if ($parsed === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $parsed['path'] ?? '';
|
||||
|
||||
if ($path === '' || ! str_starts_with($path, '/event-admin')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($parsed['query'])) {
|
||||
$path .= '?'.$parsed['query'];
|
||||
}
|
||||
|
||||
if (isset($parsed['fragment'])) {
|
||||
$path .= '#'.$parsed['fragment'];
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,26 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, DashboardSummaryService $summaryService): Response
|
||||
public function __invoke(Request $request, DashboardSummaryService $summaryService)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user && $user->role === 'tenant_admin') {
|
||||
$returnTarget = $this->consumeTenantAdminTarget($request);
|
||||
|
||||
if ($returnTarget !== null) {
|
||||
return redirect($returnTarget);
|
||||
}
|
||||
}
|
||||
|
||||
// Block users with 'user' role from accessing dashboard
|
||||
if ($user && $user->role === 'user') {
|
||||
return redirect('/packages');
|
||||
}
|
||||
|
||||
$tenant = $user?->tenant;
|
||||
|
||||
$summary = $tenant instanceof Tenant
|
||||
@@ -65,6 +78,21 @@ class DashboardController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function consumeTenantAdminTarget(Request $request): ?string
|
||||
{
|
||||
$target = $request->session()->pull('tenant_admin.return_to');
|
||||
|
||||
if (! is_string($target)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! str_starts_with($target, '/event-admin')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
private function collectUpcomingEvents(Tenant $tenant): Collection
|
||||
{
|
||||
return Event::query()
|
||||
|
||||
@@ -1,884 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
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;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class OAuthController extends Controller
|
||||
{
|
||||
private const AUTH_CODE_TTL_MINUTES = 5;
|
||||
|
||||
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
||||
|
||||
private const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
|
||||
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
|
||||
|
||||
/**
|
||||
* Authorize endpoint - PKCE flow
|
||||
*/
|
||||
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',
|
||||
'response_type' => 'required|in:code',
|
||||
'scope' => 'required|string',
|
||||
'state' => 'nullable|string',
|
||||
'code_challenge' => 'required|string',
|
||||
'code_challenge_method' => 'required|in:S256,plain',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->authorizeErrorResponse(
|
||||
$request,
|
||||
'invalid_request',
|
||||
'The authorization request is invalid.',
|
||||
Response::HTTP_BAD_REQUEST,
|
||||
['errors' => $validator->errors()->toArray()]
|
||||
);
|
||||
}
|
||||
|
||||
/** @var OAuthClient|null $client */
|
||||
$client = OAuthClient::query()
|
||||
->where('client_id', $request->string('client_id'))
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $client) {
|
||||
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->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->authorizeErrorResponse(
|
||||
$request,
|
||||
'invalid_scope',
|
||||
'The client requested scopes that are not permitted.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$tenantId = $this->resolveTenantId($client, $user);
|
||||
if (! $tenantId) {
|
||||
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);
|
||||
$codeId = (string) Str::uuid();
|
||||
$expiresAt = now()->addMinutes(self::AUTH_CODE_TTL_MINUTES);
|
||||
$cacheKey = $this->cacheKeyForCode($code);
|
||||
|
||||
Cache::put($cacheKey, [
|
||||
'id' => $codeId,
|
||||
'client_id' => $client->client_id,
|
||||
'tenant_id' => $tenantId,
|
||||
'redirect_uri' => $request->redirect_uri,
|
||||
'scopes' => $requestedScopes,
|
||||
'state' => $request->state,
|
||||
'code_challenge' => $request->code_challenge,
|
||||
'code_challenge_method' => $request->code_challenge_method,
|
||||
'expires_at' => $expiresAt,
|
||||
], $expiresAt);
|
||||
|
||||
OAuthCode::create([
|
||||
'id' => $codeId,
|
||||
'client_id' => $client->client_id,
|
||||
'user_id' => (string) $user->getAuthIdentifier(),
|
||||
'code' => Hash::make($code),
|
||||
'code_challenge' => $request->code_challenge,
|
||||
'state' => $request->state,
|
||||
'redirect_uri' => $request->redirect_uri,
|
||||
'scope' => implode(' ', $requestedScopes),
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
$redirectUrl = $request->redirect_uri.'?'.http_build_query([
|
||||
'code' => $code,
|
||||
'state' => $request->state,
|
||||
]);
|
||||
|
||||
if ($this->shouldReturnJsonAuthorizeResponse($request)) {
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'state' => $request->state,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
public function token(Request $request)
|
||||
{
|
||||
$grantType = (string) $request->string('grant_type');
|
||||
|
||||
if ($grantType === 'authorization_code') {
|
||||
return $this->handleAuthorizationCodeGrant($request);
|
||||
}
|
||||
|
||||
if ($grantType === 'refresh_token') {
|
||||
return $this->handleRefreshTokenGrant($request);
|
||||
}
|
||||
|
||||
return $this->errorResponse('Unsupported grant type', 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant info based on decoded token
|
||||
*/
|
||||
public function me(Request $request)
|
||||
{
|
||||
$decoded = $request->attributes->get('decoded_token');
|
||||
$tenantId = Arr::get($decoded, 'tenant_id');
|
||||
|
||||
if (! $tenantId) {
|
||||
return $this->errorResponse('Unauthenticated', 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'id' => $tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $tenant->name,
|
||||
'slug' => $tenant->slug,
|
||||
'email' => $tenant->contact_email,
|
||||
'active_reseller_package_id' => $tenant->active_reseller_package_id,
|
||||
'remaining_events' => $tenant->activeResellerPackage?->remaining_events ?? 0,
|
||||
'package_expires_at' => $tenant->activeResellerPackage?->expires_at,
|
||||
'features' => $tenant->features,
|
||||
'scopes' => Arr::get($decoded, 'scopes', []),
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleAuthorizationCodeGrant(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'grant_type' => 'required|in:authorization_code',
|
||||
'code' => 'required|string',
|
||||
'client_id' => 'required|string',
|
||||
'redirect_uri' => 'required|url',
|
||||
'code_verifier' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
|
||||
}
|
||||
|
||||
$cacheKey = $this->cacheKeyForCode($request->code);
|
||||
$cachedCode = Cache::get($cacheKey);
|
||||
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
|
||||
Log::warning('[OAuth] Authorization code missing or expired', [
|
||||
'client_id' => $request->client_id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid or expired authorization code', 400);
|
||||
}
|
||||
|
||||
/** @var OAuthCode|null $oauthCode */
|
||||
$oauthCode = OAuthCode::query()->find($cachedCode['id']);
|
||||
if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
|
||||
Log::warning('[OAuth] Authorization code validation failed', [
|
||||
'client_id' => $request->client_id,
|
||||
'oauth_code_id' => $oauthCode?->id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid authorization code', 400);
|
||||
}
|
||||
|
||||
/** @var OAuthClient|null $client */
|
||||
$client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
|
||||
if (! $client) {
|
||||
return $this->errorResponse('Invalid client', 401);
|
||||
}
|
||||
|
||||
if ($request->redirect_uri !== Arr::get($cachedCode, 'redirect_uri')) {
|
||||
return $this->errorResponse('Invalid redirect URI', 400);
|
||||
}
|
||||
|
||||
$codeChallengeMethod = Arr::get($cachedCode, 'code_challenge_method', 'S256');
|
||||
$expectedChallenge = $codeChallengeMethod === 'S256'
|
||||
? $this->base64urlEncode(hash('sha256', $request->code_verifier, true))
|
||||
: $request->code_verifier;
|
||||
|
||||
if (! hash_equals($expectedChallenge, Arr::get($cachedCode, 'code_challenge'))) {
|
||||
return $this->errorResponse('Invalid code verifier', 400);
|
||||
}
|
||||
|
||||
$tenantId = Arr::get($cachedCode, 'tenant_id') ?? $client->tenant_id;
|
||||
$tenant = $tenantId ? Tenant::query()->find($tenantId) : null;
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'oauth_code_id' => $oauthCode->id ?? null,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
$scopes = Arr::get($cachedCode, 'scopes', []);
|
||||
if (empty($scopes)) {
|
||||
$scopes = $this->parseScopes($oauthCode->scope);
|
||||
}
|
||||
|
||||
Cache::forget($cacheKey);
|
||||
$oauthCode->delete();
|
||||
|
||||
$tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
|
||||
|
||||
return response()->json($tokenResponse);
|
||||
}
|
||||
|
||||
private function handleRefreshTokenGrant(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'grant_type' => 'required|in:refresh_token',
|
||||
'refresh_token' => 'required|string',
|
||||
'client_id' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
|
||||
}
|
||||
|
||||
$tokenParts = explode('|', $request->refresh_token, 2);
|
||||
if (count($tokenParts) !== 2) {
|
||||
return $this->errorResponse('Malformed refresh token', 400);
|
||||
}
|
||||
|
||||
[$refreshTokenId, $refreshTokenSecret] = $tokenParts;
|
||||
|
||||
/** @var RefreshToken|null $storedRefreshToken */
|
||||
$storedRefreshToken = RefreshToken::query()
|
||||
->where('id', $refreshTokenId)
|
||||
->whereNull('revoked_at')
|
||||
->first();
|
||||
|
||||
if (! $storedRefreshToken) {
|
||||
return $this->errorResponse('Invalid refresh token', 400);
|
||||
}
|
||||
|
||||
$storedRefreshToken->recordAudit('refresh_attempt', [
|
||||
'client_id' => $request->client_id,
|
||||
], null, $request);
|
||||
|
||||
if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) {
|
||||
$storedRefreshToken->recordAudit('client_mismatch', [
|
||||
'expected_client' => $storedRefreshToken->client_id,
|
||||
'provided_client' => $request->client_id,
|
||||
], null, $request);
|
||||
|
||||
return $this->errorResponse('Refresh token does not match client', 400);
|
||||
}
|
||||
|
||||
if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) {
|
||||
$storedRefreshToken->revoke('expired', null, $request, [
|
||||
'expired_at' => $storedRefreshToken->expires_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Refresh token expired', 400);
|
||||
}
|
||||
|
||||
if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) {
|
||||
$storedRefreshToken->recordAudit('invalid_secret', [], null, $request);
|
||||
$storedRefreshToken->revoke('invalid_secret', null, $request, [
|
||||
'client_id' => $request->client_id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid refresh token', 400);
|
||||
}
|
||||
|
||||
$storedIp = (string) ($storedRefreshToken->ip_address ?? '');
|
||||
$currentIp = (string) ($request->ip() ?? '');
|
||||
|
||||
if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
|
||||
Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [
|
||||
'client_id' => $request->client_id,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
'stored_ip' => $storedIp,
|
||||
'current_ip' => $currentIp,
|
||||
]);
|
||||
|
||||
$storedRefreshToken->revoke('ip_mismatch', null, $request, [
|
||||
'stored_ip' => $storedIp,
|
||||
'current_ip' => $currentIp,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
|
||||
}
|
||||
|
||||
$client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
|
||||
if (! $client) {
|
||||
return $this->errorResponse('Invalid client', 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->find($storedRefreshToken->tenant_id);
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
'tenant_id' => $storedRefreshToken->tenant_id,
|
||||
]);
|
||||
|
||||
$storedRefreshToken->revoke('tenant_missing', null, $request, [
|
||||
'missing_tenant_id' => $storedRefreshToken->tenant_id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
$scopes = $this->parseScopes($storedRefreshToken->scope);
|
||||
|
||||
$storedRefreshToken->forceFill([
|
||||
'last_used_at' => now(),
|
||||
])->save();
|
||||
|
||||
$storedRefreshToken->recordAudit('refreshed', [
|
||||
'client_id' => $request->client_id,
|
||||
], null, $request);
|
||||
|
||||
$tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
|
||||
|
||||
$newComposite = $tokenResponse['refresh_token'] ?? null;
|
||||
$newRefreshTokenId = null;
|
||||
|
||||
if ($newComposite && str_contains($newComposite, '|')) {
|
||||
[$newRefreshTokenId] = explode('|', $newComposite, 2);
|
||||
}
|
||||
|
||||
$storedRefreshToken->revoke('rotated', null, $request, [
|
||||
'replaced_by' => $newRefreshTokenId,
|
||||
]);
|
||||
|
||||
return response()->json($tokenResponse);
|
||||
}
|
||||
|
||||
private function issueTokenPair(Tenant $tenant, OAuthClient $client, array $scopes, Request $request): array
|
||||
{
|
||||
$scopes = array_values(array_unique($scopes));
|
||||
$expiresIn = self::ACCESS_TOKEN_TTL_SECONDS;
|
||||
$issuedAt = now();
|
||||
$jti = (string) Str::uuid();
|
||||
$expiresAt = $issuedAt->copy()->addSeconds($expiresIn);
|
||||
|
||||
$accessToken = $this->generateJWT(
|
||||
$tenant->id,
|
||||
$client->client_id,
|
||||
$scopes,
|
||||
'access',
|
||||
$expiresIn,
|
||||
$jti,
|
||||
$issuedAt->timestamp,
|
||||
$expiresAt->timestamp
|
||||
);
|
||||
|
||||
TenantToken::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'tenant_id' => $tenant->id,
|
||||
'jti' => $jti,
|
||||
'token_type' => 'access',
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
$refreshToken = $this->createRefreshToken($tenant, $client, $scopes, $jti, $request);
|
||||
|
||||
return [
|
||||
'token_type' => 'Bearer',
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_in' => $expiresIn,
|
||||
'scope' => implode(' ', $scopes),
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldReturnJsonAuthorizeResponse(Request $request): bool
|
||||
{
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$redirectUri = (string) $request->string('redirect_uri');
|
||||
$redirectHost = $redirectUri !== '' ? parse_url($redirectUri, PHP_URL_HOST) : null;
|
||||
$requestHost = $request->getHost();
|
||||
|
||||
if ($redirectHost && ! $this->hostsMatch($requestHost, $redirectHost)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$origin = $request->headers->get('Origin');
|
||||
if ($origin) {
|
||||
$originHost = parse_url($origin, PHP_URL_HOST);
|
||||
if ($originHost && $redirectHost && ! $this->hostsMatch($originHost, $redirectHost)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hostsMatch(?string $first, ?string $second): bool
|
||||
{
|
||||
if (! $first || ! $second) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower($first) === strtolower($second);
|
||||
}
|
||||
|
||||
private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
|
||||
{
|
||||
$refreshTokenId = (string) Str::uuid();
|
||||
$secret = Str::random(64);
|
||||
$composite = $refreshTokenId.'|'.$secret;
|
||||
$expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
|
||||
|
||||
/** @var RefreshToken $refreshToken */
|
||||
$refreshToken = RefreshToken::create([
|
||||
'id' => $refreshTokenId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'client_id' => $client->client_id,
|
||||
'token' => Hash::make($secret),
|
||||
'access_token' => $accessTokenJti,
|
||||
'expires_at' => $expiresAt,
|
||||
'last_used_at' => now(),
|
||||
'scope' => implode(' ', $scopes),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
$refreshToken->recordAudit('issued', [
|
||||
'scopes' => $scopes,
|
||||
], null, $request);
|
||||
|
||||
$maxActive = (int) config('oauth.refresh_tokens.max_active_per_tenant', 5);
|
||||
|
||||
if ($maxActive > 0) {
|
||||
$activeTokens = RefreshToken::query()
|
||||
->forTenant((string) $tenant->id)
|
||||
->active()
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
if ($activeTokens->count() > $maxActive) {
|
||||
$activeTokens
|
||||
->slice($maxActive)
|
||||
->each(function (RefreshToken $token) use ($request, $maxActive, $refreshToken): void {
|
||||
$token->revoke('max_active_limit', null, $request, [
|
||||
'threshold' => $maxActive,
|
||||
'new_token' => $refreshToken->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return $composite;
|
||||
}
|
||||
|
||||
private function generateJWT(
|
||||
int $tenantId,
|
||||
string $clientId,
|
||||
array $scopes,
|
||||
string $type,
|
||||
int $expiresIn,
|
||||
string $jti,
|
||||
int $issuedAt,
|
||||
int $expiresAt
|
||||
): string {
|
||||
[$kid, , $privateKey] = $this->getSigningKeyPair();
|
||||
|
||||
$payload = [
|
||||
'iss' => url('/'),
|
||||
'aud' => $clientId,
|
||||
'iat' => $issuedAt,
|
||||
'nbf' => $issuedAt,
|
||||
'exp' => $expiresAt,
|
||||
'sub' => $tenantId,
|
||||
'tenant_id' => $tenantId,
|
||||
'scopes' => $scopes,
|
||||
'type' => $type,
|
||||
'client_id' => $clientId,
|
||||
'jti' => $jti,
|
||||
];
|
||||
|
||||
return JWT::encode($payload, $privateKey, 'RS256', $kid, ['kid' => $kid]);
|
||||
}
|
||||
|
||||
private function getSigningKeyPair(): array
|
||||
{
|
||||
$kid = $this->currentKid();
|
||||
[$publicKey, $privateKey] = $this->ensureKeysForKid($kid);
|
||||
|
||||
return [$kid, $publicKey, $privateKey];
|
||||
}
|
||||
|
||||
private function currentKid(): string
|
||||
{
|
||||
return config('oauth.keys.current_kid', self::LEGACY_TOKEN_HEADER_KID);
|
||||
}
|
||||
|
||||
private function ensureKeysForKid(string $kid): array
|
||||
{
|
||||
$paths = $this->keyPaths($kid);
|
||||
|
||||
if (! File::exists($paths['directory'])) {
|
||||
File::makeDirectory($paths['directory'], 0700, true);
|
||||
}
|
||||
|
||||
$this->maybeMigrateLegacyKeys($paths);
|
||||
|
||||
if (! File::exists($paths['public']) || ! File::exists($paths['private'])) {
|
||||
$this->generateKeyPair($paths['directory']);
|
||||
}
|
||||
|
||||
return [
|
||||
File::get($paths['public']),
|
||||
File::get($paths['private']),
|
||||
];
|
||||
}
|
||||
|
||||
private function keyPaths(string $kid): array
|
||||
{
|
||||
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$directory = $base.DIRECTORY_SEPARATOR.$kid;
|
||||
|
||||
return [
|
||||
'directory' => $directory,
|
||||
'public' => $directory.DIRECTORY_SEPARATOR.'public.key',
|
||||
'private' => $directory.DIRECTORY_SEPARATOR.'private.key',
|
||||
];
|
||||
}
|
||||
|
||||
private function maybeMigrateLegacyKeys(array $paths): void
|
||||
{
|
||||
$legacyPublic = storage_path('app/public.key');
|
||||
$legacyPrivate = storage_path('app/private.key');
|
||||
|
||||
if (! File::exists($paths['public']) && File::exists($legacyPublic)) {
|
||||
File::copy($legacyPublic, $paths['public']);
|
||||
}
|
||||
|
||||
if (! File::exists($paths['private']) && File::exists($legacyPrivate)) {
|
||||
File::copy($legacyPrivate, $paths['private']);
|
||||
}
|
||||
}
|
||||
|
||||
private function generateKeyPair(string $directory): void
|
||||
{
|
||||
$config = [
|
||||
'digest_alg' => OPENSSL_ALGO_SHA256,
|
||||
'private_key_bits' => 4096,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$resource = openssl_pkey_new($config);
|
||||
if (! $resource) {
|
||||
throw new \RuntimeException('Failed to generate key pair');
|
||||
}
|
||||
|
||||
openssl_pkey_export($resource, $privateKey);
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicKey = $details['key'] ?? null;
|
||||
|
||||
if (! $publicKey) {
|
||||
throw new \RuntimeException('Failed to extract public key');
|
||||
}
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey, true);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600);
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||
}
|
||||
|
||||
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
|
||||
{
|
||||
if (empty($requestedScopes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$available = array_flip($availableScopes);
|
||||
foreach ($requestedScopes as $scope) {
|
||||
if (! isset($available[$scope])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function parseScopes(?string $scopeString): array
|
||||
{
|
||||
if (! $scopeString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(explode(' ', trim($scopeString))));
|
||||
}
|
||||
|
||||
private function errorResponse(string $message, int $status = 400, $errors = null)
|
||||
{
|
||||
$response = ['error' => $message];
|
||||
if ($errors) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
return response()->json($response, $status);
|
||||
}
|
||||
|
||||
private function ipMatches(string $storedIp, string $currentIp): bool
|
||||
{
|
||||
if ($storedIp === '' || $currentIp === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hash_equals($storedIp, $currentIp)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! config('oauth.refresh_tokens.allow_subnet_match', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($currentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$storedParts = explode('.', $storedIp);
|
||||
$currentParts = explode('.', $currentIp);
|
||||
|
||||
return $storedParts[0] === $currentParts[0]
|
||||
&& $storedParts[1] === $currentParts[1]
|
||||
&& $storedParts[2] === $currentParts[2];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function base64urlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function cacheKeyForCode(string $code): string
|
||||
{
|
||||
return 'oauth:code:'.hash('sha256', $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Connect OAuth - Start connection
|
||||
*/
|
||||
public function stripeConnect(Request $request)
|
||||
{
|
||||
$tenant = $request->user()->tenant ?? null;
|
||||
if (! $tenant) {
|
||||
return ApiError::response(
|
||||
'tenant_not_found',
|
||||
'Tenant not found',
|
||||
'The authenticated user is not assigned to a tenant.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$state = Str::random(40);
|
||||
session(['stripe_state' => $state, 'tenant_id' => $tenant->id]);
|
||||
Cache::put("stripe_connect_state:{$tenant->id}", $state, now()->addMinutes(10));
|
||||
|
||||
$clientId = config('services.stripe.connect_client_id');
|
||||
$redirectUri = url('/api/v1/oauth/stripe-callback');
|
||||
$scopes = 'read_write_payments transfers';
|
||||
|
||||
$authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=".urlencode($redirectUri);
|
||||
|
||||
return redirect($authUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Connect Callback
|
||||
*/
|
||||
public function stripeCallback(Request $request)
|
||||
{
|
||||
$code = $request->get('code');
|
||||
$state = $request->get('state');
|
||||
$error = $request->get('error');
|
||||
|
||||
if ($error) {
|
||||
return redirect('/event-admin')->with('error', 'Stripe connection failed: '.$error);
|
||||
}
|
||||
|
||||
if (! $code || ! $state) {
|
||||
return redirect('/event-admin')->with('error', 'Invalid callback parameters');
|
||||
}
|
||||
|
||||
$sessionState = session('stripe_state');
|
||||
if (! hash_equals($state, (string) $sessionState)) {
|
||||
return redirect('/event-admin')->with('error', 'Invalid state parameter');
|
||||
}
|
||||
|
||||
$client = new Client;
|
||||
$clientId = config('services.stripe.connect_client_id');
|
||||
$secret = config('services.stripe.connect_secret');
|
||||
$redirectUri = url('/api/v1/oauth/stripe-callback');
|
||||
|
||||
try {
|
||||
$response = $client->post('https://connect.stripe.com/oauth/token', [
|
||||
'form_params' => [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $secret,
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if (! isset($tokenData['stripe_user_id'])) {
|
||||
return redirect('/event-admin')->with('error', 'Failed to connect Stripe account');
|
||||
}
|
||||
|
||||
$tenant = Tenant::find(session('tenant_id'));
|
||||
if ($tenant) {
|
||||
$tenant->update(['stripe_account_id' => $tokenData['stripe_user_id']]);
|
||||
}
|
||||
|
||||
session()->forget(['stripe_state', 'tenant_id']);
|
||||
|
||||
return redirect('/event-admin')->with('success', 'Stripe account connected successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Stripe OAuth error: '.$e->getMessage());
|
||||
|
||||
return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Http/Controllers/TenantAdminAuthController.php
Normal file
32
app/Http/Controllers/TenantAdminAuthController.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TenantAdminAuthController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Allow only tenant_admin and super_admin
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) {
|
||||
return view('admin');
|
||||
}
|
||||
|
||||
// Redirect users with 'user' role to packages
|
||||
if ($user && $user->role === 'user') {
|
||||
return redirect('/packages');
|
||||
}
|
||||
|
||||
// Redirect unauthenticated users to the dedicated admin start flow
|
||||
if (! $user) {
|
||||
return redirect('/event-admin/start');
|
||||
}
|
||||
|
||||
// Default: redirect to regular dashboard
|
||||
return redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class TenantAdminGoogleController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->intended(route('tenant.admin.app'));
|
||||
return redirect()->intended('/event-admin/dashboard');
|
||||
}
|
||||
|
||||
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
||||
|
||||
147
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
147
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Auth\Middleware\RedirectIfAuthenticated as BaseMiddleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class RedirectIfAuthenticated extends BaseMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ...$guards)
|
||||
{
|
||||
$guards = $guards === [] ? [null] : $guards;
|
||||
|
||||
foreach ($guards as $guard) {
|
||||
if (! Auth::guard($guard)->check()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user = Auth::guard($guard)->user();
|
||||
|
||||
if ($this->shouldBypassForTenantAdmin($request, $user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($user && $user->role === 'tenant_admin') {
|
||||
$this->storeTenantAdminTarget($request);
|
||||
}
|
||||
|
||||
return redirect($this->redirectPath($user));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function shouldBypassForTenantAdmin(Request $request, ?User $user): bool
|
||||
{
|
||||
if (! $user || $user->role !== 'tenant_admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$encoded = $request->string('return_to')->trim()->toString();
|
||||
|
||||
if ($encoded === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = $this->decodeReturnTo($encoded);
|
||||
|
||||
if ($decoded === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = $this->normalizePath($decoded);
|
||||
|
||||
return str_starts_with($path, '/event-admin');
|
||||
}
|
||||
|
||||
private function decodeReturnTo(string $value): ?string
|
||||
{
|
||||
$padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '=');
|
||||
$normalized = strtr($padded, '-_', '+/');
|
||||
$decoded = base64_decode($normalized, true);
|
||||
|
||||
return $decoded === false ? null : $decoded;
|
||||
}
|
||||
|
||||
private function normalizePath(string $target): string
|
||||
{
|
||||
$trimmed = trim($target);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, '/')) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
$parsed = parse_url($trimmed);
|
||||
|
||||
if ($parsed === false || ! isset($parsed['path'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = $parsed['path'];
|
||||
|
||||
if (! str_starts_with($path, '/')) {
|
||||
$path = '/'.$path;
|
||||
}
|
||||
|
||||
if (isset($parsed['query'])) {
|
||||
$path .= '?'.$parsed['query'];
|
||||
}
|
||||
|
||||
if (isset($parsed['fragment'])) {
|
||||
$path .= '#'.$parsed['fragment'];
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function redirectPath(?User $user): string
|
||||
{
|
||||
if ($user && $user->role === 'tenant_admin') {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
if ($user && $user->role === 'user') {
|
||||
return '/packages';
|
||||
}
|
||||
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
private function storeTenantAdminTarget(Request $request): void
|
||||
{
|
||||
$encoded = $request->string('return_to')->trim()->toString();
|
||||
|
||||
if ($encoded === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$decoded = $this->decodeReturnTo($encoded);
|
||||
|
||||
if ($decoded === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $this->normalizePath($decoded);
|
||||
|
||||
if (! str_starts_with($path, '/event-admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->session()->put('tenant_admin.return_to', $path);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ class TenantIsolation
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
$abilities = $request->user()?->currentAccessToken()?->abilities ?? [];
|
||||
|
||||
if (! $tenantId && in_array('super-admin', $abilities, true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $tenantId) {
|
||||
return $this->missingTenantIdResponse();
|
||||
}
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantToken;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Auth\GenericUser;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantTokenGuard
|
||||
{
|
||||
private const LEGACY_KID = 'fotospiel-jwt';
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ...$scopes)
|
||||
{
|
||||
$token = $this->getTokenFromRequest($request);
|
||||
|
||||
if (! $token) {
|
||||
return $this->errorResponse(
|
||||
'token_missing',
|
||||
'Token Missing',
|
||||
'Authentication token not provided.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = $this->decodeToken($token);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse(
|
||||
'token_invalid',
|
||||
'Invalid Token',
|
||||
'Authentication token cannot be decoded.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->isTokenBlacklisted($decoded)) {
|
||||
return $this->errorResponse(
|
||||
'token_revoked',
|
||||
'Token Revoked',
|
||||
'The provided token is no longer valid.',
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['jti' => $decoded['jti'] ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) {
|
||||
return $this->errorResponse(
|
||||
'token_scope_violation',
|
||||
'Insufficient Scopes',
|
||||
'The provided token does not include the required scopes.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_scopes' => $scopes, 'token_scopes' => $decoded['scopes'] ?? []]
|
||||
);
|
||||
}
|
||||
|
||||
if (($decoded['exp'] ?? 0) < time()) {
|
||||
$this->blacklistToken($decoded);
|
||||
|
||||
return $this->errorResponse(
|
||||
'token_expired',
|
||||
'Token Expired',
|
||||
'Authentication token has expired.',
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['expired_at' => $decoded['exp'] ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
|
||||
if (! $tenantId) {
|
||||
return $this->errorResponse(
|
||||
'token_payload_invalid',
|
||||
'Invalid Token Payload',
|
||||
'Authentication token does not include tenant context.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
if (! $tenant) {
|
||||
return $this->errorResponse(
|
||||
'tenant_not_found',
|
||||
'Tenant Not Found',
|
||||
'The tenant belonging to the token could not be located.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['tenant_id' => $tenantId]
|
||||
);
|
||||
}
|
||||
|
||||
$scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []);
|
||||
|
||||
$principal = new GenericUser([
|
||||
'id' => $tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant' => $tenant,
|
||||
'scopes' => $scopesFromToken,
|
||||
'client_id' => $decoded['client_id'] ?? null,
|
||||
'jti' => $decoded['jti'] ?? null,
|
||||
'token_type' => $decoded['type'] ?? 'access',
|
||||
]);
|
||||
|
||||
Auth::setUser($principal);
|
||||
$request->setUserResolver(fn () => $principal);
|
||||
|
||||
$request->merge([
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
$request->attributes->set('tenant_id', $tenant->id);
|
||||
$request->attributes->set('tenant', $tenant);
|
||||
$request->attributes->set('decoded_token', $decoded);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token from request (Bearer or header)
|
||||
*/
|
||||
private function getTokenFromRequest(Request $request): ?string
|
||||
{
|
||||
$header = $request->header('Authorization');
|
||||
|
||||
if (is_string($header) && str_starts_with($header, 'Bearer ')) {
|
||||
return substr($header, 7);
|
||||
}
|
||||
|
||||
if ($request->header('X-API-Token')) {
|
||||
return $request->header('X-API-Token');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*/
|
||||
private function decodeToken(string $token): array
|
||||
{
|
||||
$kid = $this->extractKid($token);
|
||||
$publicKey = $this->loadPublicKeyForKid($kid);
|
||||
|
||||
if (! $publicKey) {
|
||||
throw new \Exception('JWT public key not found');
|
||||
}
|
||||
|
||||
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
|
||||
|
||||
return (array) $decoded;
|
||||
}
|
||||
|
||||
private function extractKid(string $token): ?string
|
||||
{
|
||||
$segments = explode('.', $token);
|
||||
if (count($segments) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decodedHeader = json_decode(base64_decode($segments[0]), true);
|
||||
|
||||
return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null;
|
||||
}
|
||||
|
||||
private function loadPublicKeyForKid(?string $kid): ?string
|
||||
{
|
||||
$resolvedKid = $kid ?? config('oauth.keys.current_kid', self::LEGACY_KID);
|
||||
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$path = $base.DIRECTORY_SEPARATOR.$resolvedKid.DIRECTORY_SEPARATOR.'public.key';
|
||||
|
||||
if (File::exists($path)) {
|
||||
return File::get($path);
|
||||
}
|
||||
|
||||
$legacyPath = storage_path('app/public.key');
|
||||
if (File::exists($legacyPath)) {
|
||||
return File::get($legacyPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is blacklisted
|
||||
*/
|
||||
private function isTokenBlacklisted(array $decoded): bool
|
||||
{
|
||||
$jti = $decoded['jti'] ?? null;
|
||||
if (! $jti) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cacheKey = "blacklisted_token:{$jti}";
|
||||
if (Cache::has($cacheKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$tokenRecord = TenantToken::query()->where('jti', $jti)->first();
|
||||
if (! $tokenRecord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($tokenRecord->revoked_at) {
|
||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) {
|
||||
$tokenRecord->update(['revoked_at' => now()]);
|
||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add token to blacklist
|
||||
*/
|
||||
private function blacklistToken(array $decoded): void
|
||||
{
|
||||
$jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '').($decoded['iat'] ?? ''));
|
||||
$cacheKey = "blacklisted_token:{$jti}";
|
||||
|
||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||
|
||||
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
|
||||
$tokenType = $decoded['type'] ?? 'access';
|
||||
|
||||
$record = TenantToken::query()->where('jti', $jti)->first();
|
||||
if ($record) {
|
||||
$record->update([
|
||||
'revoked_at' => now(),
|
||||
'expires_at' => $record->expires_at ?? now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tenantId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
TenantToken::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'tenant_id' => $tenantId,
|
||||
'jti' => $jti,
|
||||
'token_type' => $tokenType,
|
||||
'expires_at' => now(),
|
||||
'revoked_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has required scopes
|
||||
*/
|
||||
private function hasScopes(array $decoded, array $requiredScopes): bool
|
||||
{
|
||||
$tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []);
|
||||
|
||||
foreach ($requiredScopes as $scope) {
|
||||
if (! in_array($scope, $tokenScopes, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function normaliseScopes(mixed $scopes): array
|
||||
{
|
||||
if (is_array($scopes)) {
|
||||
return array_values(array_filter($scopes, fn ($scope) => $scope !== null && $scope !== ''));
|
||||
}
|
||||
|
||||
if (is_string($scopes)) {
|
||||
return array_values(array_filter(explode(' ', $scopes)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function cacheTtlFromDecoded(array $decoded): int
|
||||
{
|
||||
$exp = $decoded['exp'] ?? time();
|
||||
$ttl = max($exp - time(), 60);
|
||||
|
||||
return $ttl;
|
||||
}
|
||||
|
||||
private function errorResponse(string $code, string $title, string $message, int $status, array $meta = []): JsonResponse
|
||||
{
|
||||
return ApiError::response($code, $title, $message, $status, $meta);
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(10)->by('oauth:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
Inertia::share('locale', fn () => app()->getLocale());
|
||||
Inertia::share('analytics', static function () {
|
||||
$config = config('services.matomo');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CreditCheckMiddleware;
|
||||
use App\Http\Middleware\EnsureTenantAdminToken;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\SetLocaleFromUser;
|
||||
@@ -19,9 +20,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withCommands([
|
||||
\App\Console\Commands\OAuthRotateKeysCommand::class,
|
||||
\App\Console\Commands\OAuthListKeysCommand::class,
|
||||
\App\Console\Commands\OAuthPruneKeysCommand::class,
|
||||
\App\Console\Commands\CheckEventPackages::class,
|
||||
])
|
||||
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
|
||||
@@ -29,12 +27,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
})
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->alias([
|
||||
'tenant.token' => TenantTokenGuard::class,
|
||||
'tenant.isolation' => TenantIsolation::class,
|
||||
'package.check' => \App\Http\Middleware\PackageMiddleware::class,
|
||||
'locale' => \App\Http\Middleware\SetLocale::class,
|
||||
'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class,
|
||||
'credit.check' => CreditCheckMiddleware::class,
|
||||
'tenant.admin' => EnsureTenantAdminToken::class,
|
||||
]);
|
||||
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
@@ -34,6 +34,7 @@ class UserFactory extends Factory
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'address' => $this->faker->streetAddress(),
|
||||
'phone' => $this->faker->phoneNumber(),
|
||||
'role' => 'user', // Regular users have 'user' role
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// OAuth Clients
|
||||
if (!Schema::hasTable('oauth_clients')) {
|
||||
Schema::create('oauth_clients', function (Blueprint $table) {
|
||||
$table->string('id', 255)->primary();
|
||||
$table->string('client_id', 255)->unique();
|
||||
$table->string('client_secret', 255)->nullable();
|
||||
$table->text('redirect_uris')->nullable();
|
||||
$table->text('scopes')->default('tenant:read tenant:write');
|
||||
$table->boolean('is_active')->default(true); // From add_is_active
|
||||
$table->foreignId('tenant_id')->nullable()->after('client_secret')->constrained('tenants')->nullOnDelete(); // From add_tenant_id
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->index('tenant_id');
|
||||
});
|
||||
} else {
|
||||
if (!Schema::hasColumn('oauth_clients', 'is_active')) {
|
||||
Schema::table('oauth_clients', function (Blueprint $table) {
|
||||
$table->boolean('is_active')->default(true)->after('scopes');
|
||||
});
|
||||
}
|
||||
if (!Schema::hasColumn('oauth_clients', 'tenant_id')) {
|
||||
Schema::table('oauth_clients', function (Blueprint $table) {
|
||||
$table->foreignId('tenant_id')->nullable()->after('client_secret')->constrained('tenants')->nullOnDelete();
|
||||
$table->index('tenant_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh Tokens
|
||||
if (!Schema::hasTable('refresh_tokens')) {
|
||||
Schema::create('refresh_tokens', function (Blueprint $table) {
|
||||
$table->string('id', 255)->primary();
|
||||
$table->string('tenant_id', 255)->index();
|
||||
$table->string('client_id', 255)->nullable()->index(); // From add_client_id
|
||||
$table->string('token', 255)->unique()->index();
|
||||
$table->string('access_token', 255)->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->text('scope')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('revoked_at')->nullable();
|
||||
$table->index('expires_at');
|
||||
});
|
||||
} else {
|
||||
if (!Schema::hasColumn('refresh_tokens', 'client_id')) {
|
||||
Schema::table('refresh_tokens', function (Blueprint $table) {
|
||||
$table->string('client_id', 255)->nullable()->after('tenant_id')->index();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant Tokens
|
||||
if (!Schema::hasTable('tenant_tokens')) {
|
||||
Schema::create('tenant_tokens', function (Blueprint $table) {
|
||||
$table->string('id', 255)->primary();
|
||||
$table->string('tenant_id', 255)->index();
|
||||
$table->string('jti', 255)->unique()->index();
|
||||
$table->string('token_type', 50)->index();
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('revoked_at')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->index('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
// OAuth Codes
|
||||
if (!Schema::hasTable('oauth_codes')) {
|
||||
Schema::create('oauth_codes', function (Blueprint $table) {
|
||||
$table->string('id', 255)->primary();
|
||||
$table->string('client_id', 255);
|
||||
$table->string('user_id', 255);
|
||||
$table->string('code', 255)->unique()->index();
|
||||
$table->string('code_challenge', 255);
|
||||
$table->string('state', 255)->nullable();
|
||||
$table->string('redirect_uri', 255)->nullable();
|
||||
$table->text('scope')->nullable();
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->index('expires_at');
|
||||
$table->foreign('client_id')->references('client_id')->on('oauth_clients')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('local', 'testing')) {
|
||||
if (Schema::hasTable('oauth_codes')) {
|
||||
Schema::table('oauth_codes', function (Blueprint $table) {
|
||||
$table->dropForeign(['client_id']);
|
||||
});
|
||||
Schema::dropIfExists('oauth_codes');
|
||||
}
|
||||
if (Schema::hasColumn('refresh_tokens', 'client_id')) {
|
||||
Schema::table('refresh_tokens', function (Blueprint $table) {
|
||||
$table->dropIndex(['client_id']);
|
||||
$table->dropColumn('client_id');
|
||||
});
|
||||
}
|
||||
Schema::dropIfExists('refresh_tokens');
|
||||
Schema::dropIfExists('tenant_tokens');
|
||||
if (Schema::hasColumn('oauth_clients', 'tenant_id')) {
|
||||
Schema::table('oauth_clients', function (Blueprint $table) {
|
||||
$table->dropForeign(['tenant_id']);
|
||||
$table->dropColumn('tenant_id');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('oauth_clients', 'is_active')) {
|
||||
Schema::table('oauth_clients', function (Blueprint $table) {
|
||||
$table->dropColumn('is_active');
|
||||
});
|
||||
}
|
||||
Schema::dropIfExists('oauth_clients');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -838,10 +838,17 @@ function eventEndpoint(slug: string): string {
|
||||
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export async function getEvents(): Promise<TenantEvent[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/events');
|
||||
const data = await jsonOrThrow<EventListResponse>(response, 'Failed to load events');
|
||||
return (data.data ?? []).map(normalizeEvent);
|
||||
export async function getEvents(options?: { force?: boolean }): Promise<TenantEvent[]> {
|
||||
return cachedFetch(
|
||||
CacheKeys.events,
|
||||
async () => {
|
||||
const response = await authorizedFetch('/api/v1/tenant/events');
|
||||
const data = await jsonOrThrow<EventListResponse>(response, 'Failed to load events');
|
||||
return (data.data ?? []).map(normalizeEvent);
|
||||
},
|
||||
DEFAULT_CACHE_TTL,
|
||||
options?.force === true,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEvent(payload: EventSavePayload): Promise<{ event: TenantEvent; balance: number }> {
|
||||
@@ -851,7 +858,9 @@ export async function createEvent(payload: EventSavePayload): Promise<{ event: T
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await jsonOrThrow<CreatedEventResponse>(response, 'Failed to create event');
|
||||
return { event: normalizeEvent(data.data), balance: data.balance };
|
||||
const result = { event: normalizeEvent(data.data), balance: data.balance };
|
||||
invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateEvent(slug: string, payload: Partial<EventSavePayload>): Promise<TenantEvent> {
|
||||
@@ -861,7 +870,9 @@ export async function updateEvent(slug: string, payload: Partial<EventSavePayloa
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await jsonOrThrow<EventResponse>(response, 'Failed to update event');
|
||||
return normalizeEvent(data.data);
|
||||
const event = normalizeEvent(data.data);
|
||||
invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]);
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function getEvent(slug: string): Promise<TenantEvent> {
|
||||
@@ -1130,38 +1141,52 @@ async function fetchTenantPackagesEndpoint(): Promise<Response> {
|
||||
return first;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(): Promise<DashboardSummary | null> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/dashboard');
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.');
|
||||
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
|
||||
console.error('[API] Failed to load dashboard', response.status, payload);
|
||||
throw new Error(fallbackMessage);
|
||||
}
|
||||
const json = (await response.json()) as JsonValue;
|
||||
return normalizeDashboard(json);
|
||||
export async function getDashboardSummary(options?: { force?: boolean }): Promise<DashboardSummary | null> {
|
||||
return cachedFetch(
|
||||
CacheKeys.dashboard,
|
||||
async () => {
|
||||
const response = await authorizedFetch('/api/v1/tenant/dashboard');
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.');
|
||||
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
|
||||
console.error('[API] Failed to load dashboard', response.status, payload);
|
||||
throw new Error(fallbackMessage);
|
||||
}
|
||||
const json = (await response.json()) as JsonValue;
|
||||
return normalizeDashboard(json);
|
||||
},
|
||||
DEFAULT_CACHE_TTL,
|
||||
options?.force === true,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTenantPackagesOverview(): Promise<{
|
||||
export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{
|
||||
packages: TenantPackageSummary[];
|
||||
activePackage: TenantPackageSummary | null;
|
||||
}> {
|
||||
const response = await fetchTenantPackagesEndpoint();
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
|
||||
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
|
||||
console.error('[API] Failed to load tenant packages', response.status, payload);
|
||||
throw new Error(fallbackMessage);
|
||||
}
|
||||
const data = (await response.json()) as TenantPackagesResponse;
|
||||
const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : [];
|
||||
const activePackage = data.active_package ? normalizeTenantPackage(data.active_package) : null;
|
||||
return { packages, activePackage };
|
||||
return cachedFetch(
|
||||
CacheKeys.packages,
|
||||
async () => {
|
||||
const response = await fetchTenantPackagesEndpoint();
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
|
||||
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
|
||||
console.error('[API] Failed to load tenant packages', response.status, payload);
|
||||
throw new Error(fallbackMessage);
|
||||
}
|
||||
const data = (await response.json()) as TenantPackagesResponse;
|
||||
const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : [];
|
||||
const activePackage = data.active_package ? normalizeTenantPackage(data.active_package) : null;
|
||||
return { packages, activePackage };
|
||||
},
|
||||
DEFAULT_CACHE_TTL * 5,
|
||||
options?.force === true,
|
||||
);
|
||||
}
|
||||
|
||||
export type NotificationPreferenceResponse = {
|
||||
@@ -1651,3 +1676,67 @@ export async function removeEventMember(eventIdentifier: number | string, member
|
||||
throw new Error('Failed to remove member');
|
||||
}
|
||||
}
|
||||
type CacheEntry<T> = {
|
||||
value?: T;
|
||||
expiresAt: number;
|
||||
promise?: Promise<T>;
|
||||
};
|
||||
|
||||
const tenantApiCache = new Map<string, CacheEntry<unknown>>();
|
||||
const DEFAULT_CACHE_TTL = 60_000;
|
||||
|
||||
const CacheKeys = {
|
||||
dashboard: 'tenant:dashboard',
|
||||
events: 'tenant:events',
|
||||
packages: 'tenant:packages',
|
||||
} as const;
|
||||
|
||||
function cachedFetch<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttl: number = DEFAULT_CACHE_TTL,
|
||||
force = false,
|
||||
): Promise<T> {
|
||||
if (force) {
|
||||
tenantApiCache.delete(key);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existing = tenantApiCache.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
if (!force && existing) {
|
||||
if (existing.promise) {
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
if (existing.value !== undefined && existing.expiresAt > now) {
|
||||
return Promise.resolve(existing.value);
|
||||
}
|
||||
}
|
||||
|
||||
const promise = fetcher()
|
||||
.then((value) => {
|
||||
tenantApiCache.set(key, { value, expiresAt: Date.now() + ttl });
|
||||
return value;
|
||||
})
|
||||
.catch((error) => {
|
||||
tenantApiCache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
|
||||
tenantApiCache.set(key, { value: existing?.value, expiresAt: existing?.expiresAt ?? 0, promise });
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function invalidateTenantApiCache(keys?: string | string[]): void {
|
||||
if (!keys) {
|
||||
tenantApiCache.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Array.isArray(keys) ? keys : [keys];
|
||||
for (const key of entries) {
|
||||
tenantApiCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
authorizedFetch,
|
||||
clearOAuthSession,
|
||||
clearTokens,
|
||||
completeOAuthCallback,
|
||||
loadTokens,
|
||||
isAuthError,
|
||||
loadToken,
|
||||
registerAuthFailureHandler,
|
||||
startOAuthFlow,
|
||||
storePersonalAccessToken,
|
||||
} from './tokens';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { invalidateTenantApiCache } from '../api';
|
||||
|
||||
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
|
||||
@@ -18,30 +18,74 @@ export interface TenantProfile {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
event_credits_balance?: number;
|
||||
event_credits_balance?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: TenantProfile | null;
|
||||
login: (redirectPath?: string) => void;
|
||||
logout: (options?: { redirect?: string }) => void;
|
||||
completeLogin: (params: URLSearchParams) => Promise<string | null>;
|
||||
refreshProfile: () => Promise<void>;
|
||||
logout: (options?: { redirect?: string }) => Promise<void>;
|
||||
applyToken: (token: string, abilities: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
function getCsrfToken(): string | undefined {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null;
|
||||
return meta?.content;
|
||||
}
|
||||
|
||||
async function exchangeSessionForToken(): Promise<{ token: string; abilities: string[] } | null> {
|
||||
const csrf = getCsrfToken();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/tenant-auth/exchange', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token: string; abilities?: string[] };
|
||||
|
||||
if (!data?.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token: data.token,
|
||||
abilities: Array.isArray(data.abilities) ? data.abilities : [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Session exchange failed', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = React.useState<AuthStatus>('loading');
|
||||
const [user, setUser] = React.useState<TenantProfile | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const profileQueryKey = React.useMemo(() => ['tenantProfile'], []);
|
||||
|
||||
const handleAuthFailure = React.useCallback(() => {
|
||||
clearTokens();
|
||||
invalidateTenantApiCache();
|
||||
queryClient.removeQueries({ queryKey: profileQueryKey });
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}, []);
|
||||
}, [profileQueryKey, queryClient]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
|
||||
@@ -49,92 +93,133 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const refreshProfile = React.useCallback(async () => {
|
||||
setStatus('loading');
|
||||
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/tenant/me');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
const profile = (await response.json()) as TenantProfile;
|
||||
setUser(profile);
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: profileQueryKey,
|
||||
queryFn: async () => {
|
||||
const response = await authorizedFetch('/api/v1/tenant-auth/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tenant profile');
|
||||
}
|
||||
|
||||
return (await response.json()) as {
|
||||
user: TenantProfile | null;
|
||||
tenant?: Record<string, unknown> | null;
|
||||
abilities: string[];
|
||||
};
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
cacheTime: 1000 * 60 * 30,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const composed: TenantProfile | null = data.user && data.tenant
|
||||
? { ...data.user, ...data.tenant }
|
||||
: data.user;
|
||||
|
||||
setUser(composed ?? null);
|
||||
setStatus('authenticated');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to refresh profile', error);
|
||||
handleAuthFailure();
|
||||
|
||||
if (isAuthError(error)) {
|
||||
handleAuthFailure();
|
||||
} else {
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, [handleAuthFailure]);
|
||||
}, [handleAuthFailure, profileQueryKey, queryClient]);
|
||||
|
||||
const applyToken = React.useCallback(async (token: string, abilities: string[]) => {
|
||||
storePersonalAccessToken(token, abilities);
|
||||
await refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.has('reset-auth') || window.location.pathname === ADMIN_LOGIN_PATH) {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
const bootstrap = async () => {
|
||||
const stored = loadToken();
|
||||
if (stored) {
|
||||
try {
|
||||
await refreshProfile();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Stored token bootstrap failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshProfile().catch(() => {
|
||||
// refreshProfile already handled failures.
|
||||
const exchanged = await exchangeSessionForToken();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (exchanged) {
|
||||
await applyToken(exchanged.token, exchanged.abilities);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAuthFailure();
|
||||
};
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[Auth] Failed to bootstrap authentication', error);
|
||||
}
|
||||
handleAuthFailure();
|
||||
});
|
||||
}, [handleAuthFailure, refreshProfile]);
|
||||
|
||||
const login = React.useCallback((redirectPath?: string) => {
|
||||
const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`;
|
||||
startOAuthFlow(target);
|
||||
}, []);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyToken, handleAuthFailure, refreshProfile]);
|
||||
|
||||
const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => {
|
||||
try {
|
||||
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
|
||||
await fetch('/logout', {
|
||||
await authorizedFetch('/api/v1/tenant-auth/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);
|
||||
console.warn('[Auth] API logout failed', error);
|
||||
}
|
||||
} finally {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
try {
|
||||
const csrf = getCsrfToken();
|
||||
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] Session logout failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
handleAuthFailure();
|
||||
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeLogin = React.useCallback(
|
||||
async (params: URLSearchParams) => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
const redirectTarget = await completeOAuthCallback(params);
|
||||
await refreshProfile();
|
||||
return redirectTarget;
|
||||
} catch (error) {
|
||||
handleAuthFailure();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[handleAuthFailure, refreshProfile]
|
||||
);
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const value = React.useMemo<AuthContextValue>(
|
||||
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
|
||||
[status, user, login, logout, completeLogin, refreshProfile]
|
||||
() => ({ status, user, refreshProfile, logout, applyToken }),
|
||||
[status, user, refreshProfile, logout, applyToken]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1,42 +1,151 @@
|
||||
import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce';
|
||||
import { decodeStoredTokens } from './utils';
|
||||
import { ADMIN_AUTH_CALLBACK_PATH } from '../constants';
|
||||
const TOKEN_STORAGE_KEY = 'tenant_admin.token.v1';
|
||||
const TOKEN_SESSION_KEY = 'tenant_admin.token.session.v1';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1';
|
||||
const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier';
|
||||
const STATE_KEY = 'tenant_oauth_state';
|
||||
const REDIRECT_KEY = 'tenant_oauth_redirect';
|
||||
const TOKEN_ENDPOINT = '/api/v1/oauth/token';
|
||||
const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize';
|
||||
const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
|
||||
function getClientId(): string {
|
||||
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined;
|
||||
if (!clientId) {
|
||||
throw new Error('VITE_OAUTH_CLIENT_ID is not configured');
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function buildRedirectUri(): string {
|
||||
return new URL(ADMIN_AUTH_CALLBACK_PATH, window.location.origin).toString();
|
||||
}
|
||||
export type StoredToken = {
|
||||
accessToken: string;
|
||||
abilities: string[];
|
||||
issuedAt: number;
|
||||
};
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) {
|
||||
constructor(public code: 'unauthenticated' | 'unauthorized', message?: string) {
|
||||
super(message ?? code);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthError(value: unknown): value is AuthError {
|
||||
return value instanceof AuthError;
|
||||
let cachedToken: StoredToken | null = null;
|
||||
|
||||
function decodeStoredToken(raw: string | null): StoredToken | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredToken;
|
||||
if (!parsed || typeof parsed.accessToken !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
abilities: Array.isArray(parsed.abilities) ? parsed.abilities : [],
|
||||
issuedAt: typeof parsed.issuedAt === 'number' ? parsed.issuedAt : Date.now(),
|
||||
} satisfies StoredToken;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to decode stored token', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readTokenFromStorage(): StoredToken | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionValue = window.sessionStorage.getItem(TOKEN_SESSION_KEY);
|
||||
const fromSession = decodeStoredToken(sessionValue);
|
||||
if (fromSession) {
|
||||
return fromSession;
|
||||
}
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to read session stored token', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const persistentValue = window.localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
return decodeStoredToken(persistentValue);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to read persisted token', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadToken(): StoredToken | null {
|
||||
if (cachedToken) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
const stored = readTokenFromStorage();
|
||||
cachedToken = stored;
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
function persistToken(token: StoredToken): void {
|
||||
if (typeof window === 'undefined') {
|
||||
cachedToken = token;
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(token);
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(TOKEN_STORAGE_KEY, serialized);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to persist tenant token to localStorage', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(TOKEN_SESSION_KEY, serialized);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to persist tenant token to sessionStorage', error);
|
||||
}
|
||||
}
|
||||
|
||||
cachedToken = token;
|
||||
}
|
||||
|
||||
export function storePersonalAccessToken(accessToken: string, abilities: string[]): StoredToken {
|
||||
const stored: StoredToken = {
|
||||
accessToken,
|
||||
abilities,
|
||||
issuedAt: Date.now(),
|
||||
};
|
||||
|
||||
persistToken(stored);
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
cachedToken = null;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to remove stored tenant token', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.removeItem(TOKEN_SESSION_KEY);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to remove session tenant token', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AuthFailureHandler = () => void;
|
||||
const authFailureHandlers = new Set<AuthFailureHandler>();
|
||||
|
||||
function notifyAuthFailure() {
|
||||
function notifyAuthFailure(): void {
|
||||
authFailureHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler();
|
||||
@@ -53,214 +162,30 @@ export function registerAuthFailureHandler(handler: AuthFailureHandler): () => v
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export function loadTokens(): StoredTokens | null {
|
||||
const raw = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
const stored = decodeStoredTokens<StoredTokens>(raw);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function saveTokens(response: TokenResponse, clientId: string = getClientId()): StoredTokens {
|
||||
const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
|
||||
const stored: StoredTokens = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt,
|
||||
scope: response.scope,
|
||||
clientId,
|
||||
};
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function ensureAccessToken(): Promise<string> {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'No tokens available');
|
||||
}
|
||||
|
||||
if (Date.now() < tokens.expiresAt) {
|
||||
return tokens.accessToken;
|
||||
}
|
||||
|
||||
return refreshAccessToken(tokens);
|
||||
}
|
||||
|
||||
async function refreshAccessToken(tokens: StoredTokens): Promise<string> {
|
||||
const clientId = tokens.clientId ?? getClientId();
|
||||
|
||||
if (!tokens.refreshToken) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'Missing refresh token');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refreshToken,
|
||||
client_id: clientId,
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[Auth] Refresh token request failed', response.status);
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'Refresh token invalid');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
const stored = saveTokens(data, clientId);
|
||||
return stored.accessToken;
|
||||
export function isAuthError(value: unknown): value is AuthError {
|
||||
return value instanceof AuthError;
|
||||
}
|
||||
|
||||
export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const token = await ensureAccessToken();
|
||||
const stored = loadToken();
|
||||
if (!stored) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'No active tenant admin token');
|
||||
}
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
headers.set('Authorization', `Bearer ${stored.accessToken}`);
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(input, { ...init, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
clearTokens();
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthorized', 'Access token rejected');
|
||||
throw new AuthError('unauthorized', 'Token rejected by API');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function startOAuthFlow(redirectPath?: string): Promise<void> {
|
||||
const verifier = generateCodeVerifier();
|
||||
const state = generateState();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
||||
sessionStorage.setItem(STATE_KEY, state);
|
||||
localStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
||||
localStorage.setItem(STATE_KEY, state);
|
||||
if (redirectPath) {
|
||||
sessionStorage.setItem(REDIRECT_KEY, redirectPath);
|
||||
localStorage.setItem(REDIRECT_KEY, redirectPath);
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[Auth] PKCE store', { state, verifier, redirectPath });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: getClientId(),
|
||||
redirect_uri: buildRedirectUri(),
|
||||
scope: SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function completeOAuthCallback(params: URLSearchParams): Promise<string | null> {
|
||||
if (params.get('error')) {
|
||||
throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error');
|
||||
}
|
||||
|
||||
const code = params.get('code');
|
||||
const returnedState = params.get('state');
|
||||
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY) ?? localStorage.getItem(CODE_VERIFIER_KEY);
|
||||
const expectedState = sessionStorage.getItem(STATE_KEY) ?? localStorage.getItem(STATE_KEY);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[Auth] PKCE debug', { returnedState, expectedState, hasVerifier: !!verifier, params: params.toString() });
|
||||
}
|
||||
|
||||
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
|
||||
clearOAuthSession();
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('invalid_state', 'PKCE state mismatch');
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
localStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
localStorage.removeItem(STATE_KEY);
|
||||
|
||||
const clientId = getClientId();
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: buildRedirectUri(),
|
||||
code_verifier: verifier,
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearOAuthSession();
|
||||
console.error('[Auth] Authorization code exchange failed', response.status);
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
saveTokens(data, clientId);
|
||||
|
||||
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
||||
if (redirectTarget) {
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
localStorage.removeItem(REDIRECT_KEY);
|
||||
} else {
|
||||
localStorage.removeItem(REDIRECT_KEY);
|
||||
}
|
||||
|
||||
return redirectTarget;
|
||||
}
|
||||
|
||||
export function clearOAuthSession(): void {
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
localStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
localStorage.removeItem(STATE_KEY);
|
||||
localStorage.removeItem(REDIRECT_KEY);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { registerApiErrorListener } from '../lib/apiError';
|
||||
import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api';
|
||||
|
||||
const navItems = [
|
||||
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', icon: LayoutDashboard, end: true },
|
||||
@@ -37,6 +38,39 @@ interface AdminLayoutProps {
|
||||
|
||||
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const prefetchedPathsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const prefetchers = React.useMemo(() => ({
|
||||
[ADMIN_HOME_PATH]: () =>
|
||||
Promise.all([
|
||||
getDashboardSummary(),
|
||||
getEvents(),
|
||||
getTenantPackagesOverview(),
|
||||
]).then(() => undefined),
|
||||
[ADMIN_EVENTS_PATH]: () => getEvents().then(() => undefined),
|
||||
[ADMIN_ENGAGEMENT_PATH]: () => getEvents().then(() => undefined),
|
||||
[ADMIN_BILLING_PATH]: () => getTenantPackagesOverview().then(() => undefined),
|
||||
[ADMIN_SETTINGS_PATH]: () => Promise.resolve(),
|
||||
}), []);
|
||||
|
||||
const triggerPrefetch = React.useCallback(
|
||||
(path: string) => {
|
||||
if (prefetchedPathsRef.current.has(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runner = prefetchers[path as keyof typeof prefetchers];
|
||||
if (!runner) {
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchedPathsRef.current.add(path);
|
||||
Promise.resolve(runner()).catch(() => {
|
||||
prefetchedPathsRef.current.delete(path);
|
||||
});
|
||||
},
|
||||
[prefetchers],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('tenant-admin-theme');
|
||||
@@ -78,18 +112,21 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="hidden items-center gap-2 border-t border-white/20 px-6 py-4 md:flex">
|
||||
<nav className="hidden items-center gap-2 border-t border-slate-200/70 px-6 py-4 md:flex">
|
||||
{navItems.map(({ to, labelKey, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onPointerEnter={() => triggerPrefetch(to)}
|
||||
onFocus={() => triggerPrefetch(to)}
|
||||
onTouchStart={() => triggerPrefetch(to)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||
'flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950',
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30'
|
||||
: 'bg-white/70 text-slate-600 hover:bg-white hover:text-slate-900'
|
||||
? 'bg-rose-600 text-white shadow-md shadow-rose-400/30'
|
||||
: 'border border-slate-200/80 bg-white text-slate-700 hover:bg-rose-50/80 hover:text-rose-700'
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -101,7 +138,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto w-full max-w-6xl flex-1 px-4 pb-28 pt-6 sm:px-6">
|
||||
<main className="relative z-10 mx-auto w-full max-w-6xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+6.5rem)] pt-6 sm:px-6 md:pb-16">
|
||||
<div className="grid gap-6">{children}</div>
|
||||
</main>
|
||||
|
||||
@@ -115,26 +152,35 @@ function TenantMobileNav({ items }: { items: typeof navItems }) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<nav className="sticky bottom-4 z-40 px-4 pb-2 md:hidden">
|
||||
<div className="mx-auto flex w-full max-w-md items-center justify-around rounded-full border border-white/20 bg-white/90 p-2 text-slate-600 shadow-2xl shadow-rose-300/20 backdrop-blur-xl">
|
||||
{items.map(({ to, labelKey, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-col items-center gap-1 rounded-full px-3 py-2 text-xs font-medium transition',
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<nav className="md:hidden" aria-label={t('navigation.mobile', { defaultValue: 'Tenant Navigation' })}>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-30 h-16 bg-gradient-to-t from-slate-950/35 via-transparent to-transparent dark:from-black/60"
|
||||
/>
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200/80 bg-white/95 px-4 pb-[calc(env(safe-area-inset-bottom,0)+0.75rem)] pt-3 shadow-2xl shadow-rose-300/15 backdrop-blur supports-[backdrop-filter]:bg-white/90 dark:border-slate-800/70 dark:bg-slate-950/90">
|
||||
<div className="mx-auto flex max-w-xl items-center justify-around gap-1">
|
||||
{items.map(({ to, labelKey, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onPointerEnter={() => triggerPrefetch(to)}
|
||||
onFocus={() => triggerPrefetch(to)}
|
||||
onTouchStart={() => triggerPrefetch(to)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-xs font-semibold text-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:focus-visible:ring-offset-slate-950',
|
||||
isActive
|
||||
? 'bg-rose-600 text-white shadow-md shadow-rose-400/25'
|
||||
: 'hover:text-rose-700 dark:hover:text-rose-200'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -29,30 +29,31 @@ export function TenantHeroCard({
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border border-white/15 bg-white/95 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl',
|
||||
'dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-100',
|
||||
'relative overflow-hidden border border-slate-200/80 bg-white text-slate-900 shadow-xl shadow-rose-200/30 backdrop-blur',
|
||||
'dark:border-white/10 dark:bg-slate-950/90 dark:text-slate-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.3),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.3),_transparent_65%)] motion-safe:animate-[aurora_18s_ease-in-out_infinite]"
|
||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.18),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.16),_transparent_65%)] motion-safe:animate-[aurora_18s_ease-in-out_infinite] dark:hidden"
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950/80 via-slate-900/20 to-transparent mix-blend-overlay" />
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-rose-50/70 via-white to-sky-50/70 dark:hidden" />
|
||||
<div aria-hidden className="absolute inset-0 hidden bg-gradient-to-br from-slate-950/80 via-slate-900/20 to-transparent mix-blend-overlay dark:block" />
|
||||
|
||||
<CardContent className="relative z-10 flex flex-col gap-8 px-6 py-8 lg:flex-row lg:items-start lg:justify-between lg:px-10 lg:py-12">
|
||||
<div className="max-w-2xl space-y-6 text-white">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{badge ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/40 bg-white/15 px-4 py-1 text-xs font-semibold uppercase tracking-[0.35em]">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50/80 px-4 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-600 dark:border-white/30 dark:bg-white/15 dark:text-white">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-display text-3xl tracking-tight sm:text-4xl">{title}</h1>
|
||||
{description ? <p className="text-sm text-white/80 sm:text-base">{description}</p> : null}
|
||||
<div className="space-y-3 text-slate-700 dark:text-slate-100">
|
||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">{title}</h1>
|
||||
{description ? <p className="text-sm text-slate-600 dark:text-white/75 sm:text-base">{description}</p> : null}
|
||||
{supporting?.map((paragraph) => (
|
||||
<p key={paragraph} className="text-sm text-white/75 sm:text-base">
|
||||
<p key={paragraph} className="text-sm text-slate-600 dark:text-white/75 sm:text-base">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
@@ -60,7 +61,7 @@ export function TenantHeroCard({
|
||||
</div>
|
||||
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{primaryAction}
|
||||
{secondaryAction}
|
||||
</div>
|
||||
@@ -72,3 +73,15 @@ export function TenantHeroCard({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const tenantHeroPrimaryButtonClass = cn(
|
||||
'rounded-full bg-rose-600 px-6 text-sm font-semibold text-white shadow-md shadow-rose-400/30 transition-colors',
|
||||
'hover:bg-rose-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white',
|
||||
'dark:focus-visible:ring-offset-slate-950'
|
||||
);
|
||||
|
||||
export const tenantHeroSecondaryButtonClass = cn(
|
||||
'rounded-full border border-slate-200/80 bg-white/95 px-6 text-sm font-semibold text-slate-700 shadow-sm transition-colors',
|
||||
'hover:bg-rose-50 hover:text-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 focus-visible:ring-offset-2 focus-visible:ring-offset-white',
|
||||
'dark:border-white/20 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:focus-visible:ring-offset-slate-950'
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { TenantHeroCard } from './hero-card';
|
||||
export { TenantHeroCard, tenantHeroPrimaryButtonClass, tenantHeroSecondaryButtonClass } from './hero-card';
|
||||
export { FrostedCard, FrostedSurface, frostedCardClass } from './frosted-surface';
|
||||
export { ChecklistRow } from './checklist-row';
|
||||
export type { ChecklistStep } from './onboarding-checklist-card';
|
||||
export { TenantOnboardingChecklistCard } from './onboarding-checklist-card';
|
||||
|
||||
|
||||
@@ -1,189 +1,65 @@
|
||||
if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true') {
|
||||
type StoredTokens = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
clientId?: string;
|
||||
const CREDENTIALS: Record<string, { login: string; password: string }> = {
|
||||
lumen: { login: 'hello@lumen-moments.demo', password: 'Demo1234!' },
|
||||
storycraft: { login: 'storycraft-owner@demo.fotospiel', password: 'Demo1234!' },
|
||||
viewfinder: { login: 'team@viewfinder.demo', password: 'Demo1234!' },
|
||||
pixel: { login: 'support@pixelco.demo', password: 'Demo1234!' },
|
||||
};
|
||||
|
||||
const CLIENTS: Record<string, string> = {
|
||||
lumen: import.meta.env.VITE_OAUTH_CLIENT_ID || 'tenant-admin-app',
|
||||
storycraft: 'demo-tenant-admin-storycraft',
|
||||
viewfinder: 'demo-tenant-admin-viewfinder',
|
||||
pixel: 'demo-tenant-admin-pixel',
|
||||
};
|
||||
|
||||
const scopes = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
const baseUrl = window.location.origin;
|
||||
const redirectUri = `${baseUrl}/event-admin/auth/callback`;
|
||||
|
||||
async function loginAs(label: string): Promise<void> {
|
||||
const clientId = CLIENTS[label];
|
||||
if (!clientId) {
|
||||
console.warn('[DevAuth] Unknown tenant key', label);
|
||||
async function loginAs(key: string): Promise<void> {
|
||||
const credentials = CREDENTIALS[key];
|
||||
if (!credentials) {
|
||||
console.warn('[DevAuth] Unknown tenant key', key);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await fetchTokens(clientId);
|
||||
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
|
||||
const response = await fetch('/api/v1/tenant-auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
login: credentials.login,
|
||||
password: credentials.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Login failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { token: string; abilities?: string[] };
|
||||
const stored = {
|
||||
accessToken: payload.token,
|
||||
abilities: Array.isArray(payload.abilities) ? payload.abilities : [],
|
||||
issuedAt: Date.now(),
|
||||
} satisfies { accessToken: string; abilities: string[]; issuedAt: number };
|
||||
|
||||
try {
|
||||
window.localStorage.setItem('tenant_admin.token.v1', JSON.stringify(stored));
|
||||
} catch (error) {
|
||||
console.warn('[DevAuth] Failed to persist PAT to localStorage', error);
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem('tenant_admin.token.session.v1', JSON.stringify(stored));
|
||||
} catch (error) {
|
||||
console.warn('[DevAuth] Failed to persist PAT to sessionStorage', error);
|
||||
}
|
||||
|
||||
window.location.assign('/event-admin/dashboard');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[DevAuth] Failed to login', message);
|
||||
console.error('[DevAuth] Demo login failed', message);
|
||||
throw error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTokens(clientId: string): Promise<StoredTokens> {
|
||||
const verifier = randomString(32);
|
||||
const challenge = await sha256(verifier);
|
||||
const state = randomString(12);
|
||||
|
||||
const authorizeParams = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`, redirectUri);
|
||||
verifyState(callbackUrl.searchParams.get('state'), state);
|
||||
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
if (!code) {
|
||||
throw new Error('Authorize response missing code');
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch('/api/v1/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed with ${tokenResponse.status}`);
|
||||
}
|
||||
|
||||
const body = await tokenResponse.json();
|
||||
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
|
||||
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
||||
scope: body.scope,
|
||||
clientId,
|
||||
};
|
||||
}
|
||||
|
||||
function randomString(bytes: number): string {
|
||||
const buffer = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buffer);
|
||||
return base64Url(buffer);
|
||||
}
|
||||
|
||||
async function sha256(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64Url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function base64Url(data: Uint8Array): string {
|
||||
const binary = Array.from(data, (byte) => String.fromCharCode(byte)).join('');
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
const api = { loginAs, clients: CLIENTS };
|
||||
|
||||
console.info('[DevAuth] Demo tenant helpers ready', Object.keys(CLIENTS));
|
||||
const api = { loginAs, clients: Object.keys(CREDENTIALS) };
|
||||
console.info('[DevAuth] Demo tenant helpers ready', api.clients);
|
||||
|
||||
(window as typeof window & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
|
||||
(globalThis as typeof globalThis & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
|
||||
}
|
||||
|
||||
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
const requestUrl = new URL(url, window.location.origin);
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSuccess = (xhr.status >= 200 && xhr.status < 400) || xhr.status === 0;
|
||||
if (!isSuccess) {
|
||||
reject(new Error(`Authorize failed with ${xhr.status}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = xhr.getResponseHeader('Content-Type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const payload = JSON.parse(xhr.responseText ?? '{}') as {
|
||||
code?: string;
|
||||
state?: string | null;
|
||||
redirect_url?: string | null;
|
||||
};
|
||||
const target = payload.redirect_url ?? fallbackRedirect;
|
||||
if (!target) {
|
||||
throw new Error('Authorize response missing redirect target');
|
||||
}
|
||||
|
||||
const finalUrl = new URL(target, window.location.origin);
|
||||
if (payload.code && !finalUrl.searchParams.has('code')) {
|
||||
finalUrl.searchParams.set('code', payload.code);
|
||||
}
|
||||
if (payload.state && !finalUrl.searchParams.has('state')) {
|
||||
finalUrl.searchParams.set('state', payload.state);
|
||||
}
|
||||
|
||||
resolve(finalUrl);
|
||||
return;
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
|
||||
if (responseUrl) {
|
||||
const finalUrl = new URL(responseUrl, window.location.origin);
|
||||
if (finalUrl.searchParams.has('code') || finalUrl.toString() !== requestUrl.toString()) {
|
||||
resolve(finalUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackRedirect) {
|
||||
resolve(new URL(fallbackRedirect, window.location.origin));
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Authorize response missing redirect target'));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Authorize request failed'));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function verifyState(returnedState: string | null, expectedState: string): void {
|
||||
if (returnedState && returnedState !== expectedState) {
|
||||
throw new Error('Authorize state mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { ADMIN_BASE_PATH, ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH } from '../constants';
|
||||
|
||||
const LAST_DESTINATION_KEY = 'tenant.oauth.lastDestination';
|
||||
const DASHBOARD_PREFIX = '/dashboard';
|
||||
|
||||
function ensureLeadingSlash(target: string): string {
|
||||
if (!target) {
|
||||
return '/';
|
||||
@@ -15,14 +12,6 @@ function ensureLeadingSlash(target: string): string {
|
||||
return target.startsWith('/') ? target : `/${target}`;
|
||||
}
|
||||
|
||||
function matchesDashboardScope(path: string): boolean {
|
||||
return (
|
||||
path === DASHBOARD_PREFIX ||
|
||||
path.startsWith(`${DASHBOARD_PREFIX}?`) ||
|
||||
path.startsWith(`${DASHBOARD_PREFIX}/`)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPermittedReturnTarget(target: string): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
@@ -30,11 +19,20 @@ export function isPermittedReturnTarget(target: string): boolean {
|
||||
|
||||
const sanitized = ensureLeadingSlash(target);
|
||||
|
||||
if (sanitized.startsWith(ADMIN_BASE_PATH)) {
|
||||
return true;
|
||||
if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(sanitized)) {
|
||||
try {
|
||||
const url = new URL(sanitized);
|
||||
return url.pathname.startsWith(ADMIN_BASE_PATH);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth] Failed to parse return target URL', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return matchesDashboardScope(sanitized);
|
||||
return sanitized.startsWith(ADMIN_BASE_PATH);
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string): string {
|
||||
@@ -135,69 +133,3 @@ export function resolveReturnTarget(raw: string | null, fallback: string): Retur
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,20 @@ const enableDevSwitcher = import.meta.env.DEV || import.meta.env.VITE_ENABLE_TEN
|
||||
|
||||
initializeTheme();
|
||||
const rootEl = document.getElementById('root')!;
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
cacheTime: 1000 * 60 * 5,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const { completeLogin } = useAuth();
|
||||
export default function AuthCallbackPage(): JSX.Element {
|
||||
const { status } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const hasHandledRef = React.useRef(false);
|
||||
const [redirected, setRedirected] = React.useState(false);
|
||||
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
|
||||
const fallback = ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const destination = React.useMemo(() => {
|
||||
if (rawReturnTo) {
|
||||
const decoded = decodeReturnTo(rawReturnTo);
|
||||
if (decoded) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveReturnTarget(null, fallback).finalTarget;
|
||||
}, [fallback, rawReturnTo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasHandledRef.current) {
|
||||
if (status !== 'authenticated' || redirected) {
|
||||
return;
|
||||
}
|
||||
hasHandledRef.current = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
completeLogin(params)
|
||||
.then((redirectTo) => {
|
||||
navigate(redirectTo ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH, { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Auth] Callback processing failed', err);
|
||||
if (isAuthError(err) && err.code === 'token_exchange_failed') {
|
||||
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
} else if (isAuthError(err) && err.code === 'invalid_state') {
|
||||
setError('Ungültiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
|
||||
} else {
|
||||
setError('Unbekannter Fehler beim Login.');
|
||||
}
|
||||
});
|
||||
}, [completeLogin, navigate]);
|
||||
setRedirected(true);
|
||||
navigate(destination, { replace: true });
|
||||
}, [destination, navigate, redirected, status]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet ...</span>
|
||||
{error && <div className="max-w-sm rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet …</span>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
Bitte warte einen Moment. Wir richten dein Dashboard ein.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
FrostedCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
|
||||
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
|
||||
|
||||
@@ -60,12 +66,12 @@ export default function BillingPage() {
|
||||
[t]
|
||||
);
|
||||
|
||||
const loadAll = React.useCallback(async () => {
|
||||
const loadAll = React.useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [packagesResult, paddleTransactions] = await Promise.all([
|
||||
getTenantPackagesOverview(),
|
||||
getTenantPackagesOverview(force ? { force: true } : undefined),
|
||||
getTenantPaddleTransactions().catch((err) => {
|
||||
console.warn('Failed to load Paddle transactions', err);
|
||||
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
|
||||
@@ -125,8 +131,8 @@ export default function BillingPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
onClick={() => void loadAll()}
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => void loadAll(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
@@ -136,8 +142,7 @@ export default function BillingPage() {
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => window.location.assign(packagesHref)}
|
||||
>
|
||||
{t('billing.actions.explorePackages', 'Pakete vergleichen')}
|
||||
|
||||
@@ -21,7 +21,13 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { TenantHeroCard, TenantOnboardingChecklistCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
TenantOnboardingChecklistCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
import type { ChecklistStep } from '../components/tenant';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
@@ -399,7 +405,7 @@ export default function DashboardPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
if (readiness.hasEvent) {
|
||||
navigate(ADMIN_EVENTS_PATH);
|
||||
@@ -414,8 +420,7 @@ export default function DashboardPage() {
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => window.location.assign('/dashboard')}
|
||||
>
|
||||
{marketingDashboardLabel}
|
||||
|
||||
@@ -7,7 +7,13 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
FrostedCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
import { TasksSection } from './TasksPage';
|
||||
import { TaskCollectionsSection } from './TaskCollectionsPage';
|
||||
import { EmotionsSection } from './EmotionsPage';
|
||||
@@ -54,7 +60,7 @@ export default function EngagementPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => handleTabChange('tasks')}
|
||||
>
|
||||
{t('engagement.hero.actions.tasks', 'Zu Aufgaben wechseln')}
|
||||
@@ -63,8 +69,7 @@ export default function EngagementPage() {
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => handleTabChange('collections')}
|
||||
>
|
||||
{t('engagement.hero.actions.collections', 'Kollektionen ansehen')}
|
||||
|
||||
@@ -6,7 +6,13 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
FrostedCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
@@ -93,14 +99,14 @@ export default function EventsPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => navigate(adminPath('/events/new'))}
|
||||
>
|
||||
{t('events.list.actions.create', 'Neues Event')}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
<Button size="sm" variant="outline" className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white" asChild>
|
||||
<Button size="sm" className={tenantHeroSecondaryButtonClass} asChild>
|
||||
<Link to={ADMIN_SETTINGS_PATH}>
|
||||
{t('events.list.actions.settings', 'Einstellungen')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -1,242 +1,181 @@
|
||||
import React from 'react';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import {
|
||||
buildAdminOAuthStartPath,
|
||||
buildMarketingLoginUrl,
|
||||
encodeReturnTo,
|
||||
isPermittedReturnTarget,
|
||||
resolveReturnTarget,
|
||||
storeLastDestination,
|
||||
} from '../lib/returnTo';
|
||||
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
type LoginResponse = {
|
||||
token: string;
|
||||
token_type: string;
|
||||
abilities: string[];
|
||||
};
|
||||
|
||||
async function performLogin(payload: { login: string; password: string; return_to?: string | null }): Promise<LoginResponse> {
|
||||
const response = await fetch('/api/v1/tenant-auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
remember: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 422) {
|
||||
const data = await response.json();
|
||||
const errors = data.errors ?? {};
|
||||
const flattened = Object.values(errors).flat();
|
||||
throw new Error(flattened.join(' ') || 'Validation failed');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Login failed.');
|
||||
}
|
||||
|
||||
return (await response.json()) as LoginResponse;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { status, login } = useAuth();
|
||||
export default function LoginPage(): JSX.Element {
|
||||
const { status, applyToken } = 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 state = location.state as LocationState | null;
|
||||
const fallbackTarget = React.useMemo(() => {
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
const composed = `${from.pathname}${search}${hash}`;
|
||||
if (isPermittedReturnTarget(composed)) {
|
||||
return composed;
|
||||
}
|
||||
}
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [state]);
|
||||
const { finalTarget, encodedFinal } = React.useMemo(
|
||||
|
||||
const fallbackTarget = ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const { finalTarget } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||
[fallbackTarget, rawReturnTo]
|
||||
[rawReturnTo, fallbackTarget]
|
||||
);
|
||||
|
||||
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(finalTarget, { replace: true });
|
||||
}
|
||||
}, [finalTarget, navigate, status]);
|
||||
|
||||
const redirectTarget = React.useMemo(() => {
|
||||
if (finalTarget) {
|
||||
return finalTarget;
|
||||
}
|
||||
const [login, setLogin] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
const path = `${from.pathname}${search}${hash}`;
|
||||
const mutation = useMutation({
|
||||
mutationKey: ['tenantAdminLogin'],
|
||||
mutationFn: performLogin,
|
||||
onError: (err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setError(null);
|
||||
await applyToken(data.token, data.abilities ?? []);
|
||||
navigate(finalTarget, { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
}
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [finalTarget, 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]);
|
||||
mutation.mutate({
|
||||
login,
|
||||
password,
|
||||
return_to: rawReturnTo,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-svh overflow-hidden bg-slate-950 px-4 py-16 text-white">
|
||||
<div className="relative min-h-svh overflow-hidden bg-slate-950 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 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">
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-md flex-col gap-10 px-6 py-16">
|
||||
<header 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>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t('login.panel_title', t('login.title', 'Event Admin Login'))}
|
||||
</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.')}
|
||||
{t('login.panel_copy', 'Sign in with your Fotospiel admin credentials to manage your events and galleries.')}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{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}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5 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="grid gap-2">
|
||||
<Label htmlFor="login" className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('login.username_or_email', 'E-Mail oder Benutzername')}
|
||||
</Label>
|
||||
<Input
|
||||
id="login"
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={login}
|
||||
onChange={(event) => setLogin(event.target.value)}
|
||||
className="h-12 rounded-xl border-slate-200/60 bg-white/90 px-4 text-base shadow-inner shadow-slate-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-slate-800/70 dark:bg-slate-900/70 dark:text-slate-100"
|
||||
placeholder={t('login.username_or_email_placeholder', 'name@example.com')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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.')}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password" className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{t('login.password', 'Passwort')}
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="h-12 rounded-xl border-slate-200/60 bg-white/90 px-4 text-base shadow-inner shadow-slate-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-slate-800/70 dark:bg-slate-900/70 dark:text-slate-100"
|
||||
placeholder={t('login.password_placeholder', '••••••••')}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('login.remember_hint', 'Wir halten dich automatisch angemeldet, solange du dieses Gerät nutzt.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] focus-visible:ring-4 focus-visible:ring-rose-400/40"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (shouldOpenAccountLogin) {
|
||||
window.location.href = marketingLoginUrl;
|
||||
return;
|
||||
}
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="rounded-2xl border-rose-400/50 bg-rose-50/90 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
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>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-2 h-12 w-full justify-center rounded-xl bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
disabled={mutation.isLoading}
|
||||
>
|
||||
{mutation.isLoading ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : null}
|
||||
{mutation.isLoading ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
|
||||
<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}
|
||||
<footer className="text-center text-xs text-white/50">
|
||||
{t('login.support', "Brauchen Sie Hilfe? Schreiben Sie uns an support@fotospiel.de")}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,78 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import {
|
||||
buildAdminOAuthStartPath,
|
||||
buildMarketingLoginUrl,
|
||||
isPermittedReturnTarget,
|
||||
resolveReturnTarget,
|
||||
storeLastDestination,
|
||||
} from '../lib/returnTo';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
}
|
||||
|
||||
export default function LoginStartPage() {
|
||||
const { status, login } = useAuth();
|
||||
export default function LoginStartPage(): JSX.Element {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const locationState = location.state as LocationState | null;
|
||||
const fallbackTarget = React.useMemo(() => {
|
||||
const from = locationState?.from;
|
||||
if (from) {
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
const combined = `${from.pathname}${search}${hash}`;
|
||||
if (isPermittedReturnTarget(combined)) {
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [locationState]);
|
||||
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
const { finalTarget, encodedFinal } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||
[fallbackTarget, rawReturnTo]
|
||||
);
|
||||
|
||||
const [hasStarted, setHasStarted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
navigate(finalTarget, { replace: true });
|
||||
return;
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const returnTo = params.get('return_to');
|
||||
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
|
||||
if (returnTo) {
|
||||
target.searchParams.set('return_to', returnTo);
|
||||
}
|
||||
|
||||
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]);
|
||||
navigate(`${target.pathname}${target.search}`, { replace: true });
|
||||
}, [location.search, navigate]);
|
||||
|
||||
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>
|
||||
<p className="text-sm font-medium">Weiterleitung zum Login …</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
FrostedCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH, ADMIN_PROFILE_PATH } from '../constants';
|
||||
import { encodeReturnTo } from '../lib/returnTo';
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
updateNotificationPreferences,
|
||||
@@ -43,7 +49,7 @@ export default function SettingsPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => navigate(ADMIN_PROFILE_PATH)}
|
||||
>
|
||||
{t('settings.hero.actions.profile', 'Profil bearbeiten')}
|
||||
@@ -52,8 +58,7 @@ export default function SettingsPage() {
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
>
|
||||
{t('settings.hero.actions.events', 'Zur Event-Übersicht')}
|
||||
@@ -79,10 +84,10 @@ export default function SettingsPage() {
|
||||
);
|
||||
|
||||
function handleLogout() {
|
||||
const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
|
||||
let marketingUrl = buildMarketingLoginUrl(targetPath);
|
||||
marketingUrl += marketingUrl.includes('?') ? '&reset-auth=1' : '?reset-auth=1';
|
||||
logout({ redirect: marketingUrl });
|
||||
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
|
||||
target.searchParams.set('reset-auth', '1');
|
||||
target.searchParams.set('return_to', encodeReturnTo(ADMIN_EVENTS_PATH));
|
||||
logout({ redirect: `${target.pathname}${target.search}` });
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget } from '../lib/returnTo';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
@@ -37,10 +37,9 @@ export default function WelcomeTeaserPage() {
|
||||
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;
|
||||
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
|
||||
target.searchParams.set('return_to', encodedFinal ?? encodeReturnTo(finalTarget));
|
||||
window.location.href = `${target.pathname}${target.search}`;
|
||||
}, [isRedirecting]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ADMIN_LOGIN_START_PATH,
|
||||
ADMIN_PUBLIC_LANDING_PATH,
|
||||
} from './constants';
|
||||
import { consumeLastDestination } from './lib/returnTo';
|
||||
const LoginPage = React.lazy(() => import('./pages/LoginPage'));
|
||||
const DashboardPage = React.lazy(() => import('./pages/DashboardPage'));
|
||||
const EventsPage = React.lazy(() => import('./pages/EventsPage'));
|
||||
@@ -57,7 +56,6 @@ function RequireAuth() {
|
||||
|
||||
function LandingGate() {
|
||||
const { status } = useAuth();
|
||||
const lastDestinationRef = React.useRef<string | null>(null);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
@@ -68,11 +66,7 @@ function LandingGate() {
|
||||
}
|
||||
|
||||
if (status === 'authenticated') {
|
||||
if (lastDestinationRef.current === null) {
|
||||
lastDestinationRef.current = consumeLastDestination() ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}
|
||||
|
||||
return <Navigate to={lastDestinationRef.current ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
|
||||
return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
|
||||
}
|
||||
|
||||
return <WelcomeTeaserPage />;
|
||||
|
||||
@@ -4,17 +4,19 @@ import { type User } from '@/types';
|
||||
|
||||
export function UserInfo({ user, showEmail = false }: { user: User; showEmail?: boolean }) {
|
||||
const getInitials = useInitials();
|
||||
const displayName = user.name?.trim() ?? '';
|
||||
const initials = getInitials(displayName || user.email);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar className="h-8 w-8 overflow-hidden rounded-full">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarImage src={user.avatar} alt={displayName || user.email || 'Avatar'} />
|
||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{getInitials(user.name)}
|
||||
{initials || '∘'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate font-medium">{displayName || user.email}</span>
|
||||
{showEmail && <span className="truncate text-xs text-muted-foreground">{user.email}</span>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useInitials() {
|
||||
return useCallback((fullName: string): string => {
|
||||
const names = fullName.trim().split(' ');
|
||||
return useCallback((fullName?: string | null): string => {
|
||||
if (!fullName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const names = fullName
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.filter((segment) => segment.length > 0);
|
||||
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return names[0].charAt(0).toUpperCase();
|
||||
|
||||
@@ -10,10 +10,17 @@ return [
|
||||
'login' => [
|
||||
'title' => 'Anmelden',
|
||||
'username_or_email' => 'Username oder E-Mail',
|
||||
'username_or_email_placeholder' => 'name@example.com',
|
||||
'password' => 'Passwort',
|
||||
'password_placeholder' => 'Dein Passwort',
|
||||
'remember' => 'Angemeldet bleiben',
|
||||
'remember_me' => 'Angemeldet bleiben',
|
||||
'submit' => 'Anmelden',
|
||||
'panel_title' => 'Event-Admin',
|
||||
'panel_copy' => 'Melde dich mit deinen Fotospiel Admin-Zugangsdaten an, um fortzufahren.',
|
||||
'remember_hint' => 'Wir lassen dich auf diesem Gerät angemeldet.',
|
||||
'loading' => 'Anmeldung …',
|
||||
'support' => 'Brauchen Sie Hilfe? Schreiben Sie an support@fotospiel.de.',
|
||||
],
|
||||
|
||||
'register' => [
|
||||
@@ -37,6 +44,8 @@ return [
|
||||
],
|
||||
'verify_email' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse. Wir haben dir eine Bestätigungs-E-Mail geschickt.',
|
||||
'no_tenant_associated' => 'Deinem Konto ist kein Tenant zugeordnet. Bitte kontaktiere den Support.',
|
||||
'not_authorized' => 'Du bist nicht berechtigt, auf das Tenant-Dashboard zuzugreifen.',
|
||||
'unverified' => 'Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.',
|
||||
'header' => [
|
||||
'home' => 'Startseite',
|
||||
'packages' => 'Pakete',
|
||||
|
||||
@@ -9,11 +9,20 @@ return [
|
||||
'login' => [
|
||||
'title' => 'Sign in',
|
||||
'username_or_email' => 'Username or email address',
|
||||
'username_or_email_placeholder' => 'name@example.com',
|
||||
'password' => 'Password',
|
||||
'password_placeholder' => 'Your password',
|
||||
'remember' => 'Stay signed in',
|
||||
'remember_me' => 'Stay signed in',
|
||||
'submit' => 'Sign in',
|
||||
'panel_title' => 'Tenant Admin',
|
||||
'panel_copy' => 'Sign in with your Fotospiel admin credentials to continue.',
|
||||
'remember_hint' => 'We will keep you signed in on this device.',
|
||||
'loading' => 'Signing in …',
|
||||
'support' => 'Need help? Contact support@fotospiel.de.',
|
||||
],
|
||||
'verify_email' => 'Your email address is not verified. Please check your inbox for the verification link.',
|
||||
'no_tenant_associated' => 'We could not find a tenant for your account. Please contact support.',
|
||||
'not_authorized' => 'You are not authorized to access the tenant dashboard.',
|
||||
'unverified' => 'Please verify your email address before signing in.',
|
||||
];
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
use App\Http\Controllers\Api\Tenant\TenantAdminTokenController;
|
||||
use App\Http\Controllers\Api\Tenant\TenantFeedbackController;
|
||||
use App\Http\Controllers\Api\TenantBillingController;
|
||||
use App\Http\Controllers\Api\TenantPackageController;
|
||||
@@ -36,6 +37,23 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token');
|
||||
});
|
||||
|
||||
Route::prefix('tenant-auth')->name('tenant-auth.')->group(function () {
|
||||
Route::post('/login', [TenantAdminTokenController::class, 'store'])
|
||||
->middleware('throttle:tenant-auth')
|
||||
->name('login');
|
||||
|
||||
Route::middleware([EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class])->group(function () {
|
||||
Route::post('/exchange', [TenantAdminTokenController::class, 'exchange'])
|
||||
->middleware('throttle:tenant-auth')
|
||||
->name('exchange');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'tenant.admin'])->group(function () {
|
||||
Route::post('/logout', [TenantAdminTokenController::class, 'destroy'])->name('logout');
|
||||
Route::get('/me', [TenantAdminTokenController::class, 'me'])->name('me');
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware('throttle:100,1')->group(function () {
|
||||
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
|
||||
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
||||
@@ -61,7 +79,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->name('gallery.photos.asset');
|
||||
});
|
||||
|
||||
Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
|
||||
Route::middleware(['auth:sanctum', 'tenant.admin', '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('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show');
|
||||
|
||||
@@ -12,6 +12,7 @@ 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\TenantAdminAuthController;
|
||||
use App\Http\Controllers\TenantAdminGoogleController;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -244,14 +245,19 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
Route::prefix('event-admin')->group(function () {
|
||||
$renderAdmin = fn () => view('admin');
|
||||
$authAdmin = TenantAdminAuthController::class;
|
||||
|
||||
// Public routes (auth check inside controller)
|
||||
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');
|
||||
|
||||
// Protected routes (auth check inside controller)
|
||||
Route::get('/logout', $authAdmin)->name('tenant.admin.logout');
|
||||
Route::get('/dashboard', $authAdmin)->name('tenant.admin.dashboard');
|
||||
Route::get('/{view?}', $renderAdmin)
|
||||
->where('view', '.*')
|
||||
->name('tenant.admin.app');
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace Tests\Feature\Auth;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\WithFaker;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
@@ -28,7 +27,7 @@ class AuthenticationTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
$response->assertRedirect('/packages');
|
||||
}
|
||||
|
||||
public function test_users_can_authenticate_with_username()
|
||||
@@ -41,7 +40,7 @@ class AuthenticationTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
$response->assertRedirect('/packages');
|
||||
}
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password()
|
||||
|
||||
@@ -25,8 +25,8 @@ class LoginTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$expectedDefault = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
|
||||
$response->assertRedirect($expectedDefault);
|
||||
// User without specific role (null/default) redirects to packages for package selection
|
||||
$response->assertRedirect('/packages');
|
||||
$this->assertEquals('valid@example.com', Auth::user()->email);
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ class LoginTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$expectedDefault = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
|
||||
$response->assertRedirect($expectedDefault);
|
||||
// User without specific role (null/default) redirects to packages for package selection
|
||||
$response->assertRedirect('/packages');
|
||||
$this->assertEquals('validuser', Auth::user()->username);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ class LoginTest extends TestCase
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'success@example.com',
|
||||
'role' => 'user', // Regular user
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
@@ -82,8 +83,8 @@ class LoginTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$expected = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
|
||||
$response->assertRedirect($expected);
|
||||
// Regular users now redirect to /packages for package selection
|
||||
$response->assertRedirect('/packages');
|
||||
$response->assertSessionHas('success', 'Sie sind nun eingeloggt.');
|
||||
}
|
||||
|
||||
@@ -91,11 +92,13 @@ class LoginTest extends TestCase
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'return@example.com',
|
||||
'role' => 'user', // Regular user
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$target = route('tenant.admin.app', absolute: false);
|
||||
// Test that return_to parameter is honored - set it to a specific dashboard path
|
||||
$target = '/marketing/profile';
|
||||
$encoded = rtrim(strtr(base64_encode($target), '+/', '-_'), '=');
|
||||
|
||||
$response = $this->post(route('login.store'), [
|
||||
@@ -108,6 +111,99 @@ class LoginTest extends TestCase
|
||||
$response->assertRedirect($target);
|
||||
}
|
||||
|
||||
public function test_login_prefers_intended_url_over_return_to_parameter()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'tenant@example.com',
|
||||
'role' => 'tenant_admin',
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$intended = 'http://localhost/api/v1/oauth/authorize?client_id=tenant-admin-app&response_type=code';
|
||||
$returnTarget = '/event-admin/dashboard';
|
||||
$encodedReturn = rtrim(strtr(base64_encode($returnTarget), '+/', '-_'), '=');
|
||||
|
||||
$response = $this
|
||||
->withSession(['url.intended' => $intended])
|
||||
->post(route('login.store'), [
|
||||
'login' => 'tenant@example.com',
|
||||
'password' => 'password',
|
||||
'return_to' => $encodedReturn,
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$this->assertSame($user->id, Auth::id());
|
||||
$response->assertRedirect($intended);
|
||||
}
|
||||
|
||||
public function test_tenant_admin_login_with_absolute_intended_redirects_to_event_admin_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'absolute@example.com',
|
||||
'role' => 'tenant_admin',
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$intended = url('/event-admin/dashboard?from=oauth');
|
||||
|
||||
$response = $this
|
||||
->withSession(['url.intended' => $intended])
|
||||
->post(route('login.store'), [
|
||||
'login' => 'absolute@example.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$response->assertRedirect($intended);
|
||||
|
||||
$dashboardResponse = $this->get('/dashboard');
|
||||
$dashboardResponse->assertRedirect('/event-admin/dashboard?from=oauth');
|
||||
}
|
||||
|
||||
public function test_tenant_admin_login_ignores_non_admin_return_path()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'admin@example.com',
|
||||
'role' => 'tenant_admin',
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$encodedReturn = rtrim(strtr(base64_encode('/dashboard'), '+/', '-_'), '=');
|
||||
|
||||
$response = $this->post(route('login.store'), [
|
||||
'login' => 'admin@example.com',
|
||||
'password' => 'password',
|
||||
'return_to' => $encodedReturn,
|
||||
]);
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$response->assertRedirect('/event-admin/dashboard');
|
||||
}
|
||||
|
||||
public function test_tenant_admin_can_access_login_with_admin_return_path_when_authenticated()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'already@logged.in',
|
||||
'role' => 'tenant_admin',
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$adminStart = '/event-admin/start?return_to='.rtrim(strtr(base64_encode('/event-admin/dashboard'), '+/', '-_'), '=');
|
||||
$encoded = rtrim(strtr(base64_encode($adminStart), '+/', '-_'), '=');
|
||||
|
||||
$response = $this->get('/de/login?return_to='.$encoded);
|
||||
$response->assertRedirect('/dashboard');
|
||||
|
||||
$eventAdminResponse = $this->get('/event-admin/dashboard');
|
||||
$eventAdminResponse->assertOk();
|
||||
}
|
||||
|
||||
public function test_login_redirects_unverified_user_to_verification_notice()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
|
||||
70
tests/Feature/Auth/RoleBasedLoginTest.php
Normal file
70
tests/Feature/Auth/RoleBasedLoginTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RoleBasedLoginTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_tenant_admin_redirects_to_event_admin_dashboard()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'tenant@example.com',
|
||||
'role' => 'tenant_admin',
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->post(route('login.store'), [
|
||||
'login' => 'tenant@example.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect('/event-admin/dashboard');
|
||||
$this->assertEquals('tenant@example.com', Auth::user()->email);
|
||||
}
|
||||
|
||||
public function test_super_admin_redirects_to_admin_panel()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'super@example.com',
|
||||
'role' => 'super_admin',
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->post(route('login.store'), [
|
||||
'login' => 'super@example.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect('/admin');
|
||||
$this->assertEquals('super@example.com', Auth::user()->email);
|
||||
}
|
||||
|
||||
public function test_regular_user_redirects_to_packages()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'regular@example.com',
|
||||
'role' => 'user', // Regular user with 'user' role
|
||||
'password' => bcrypt('password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->post(route('login.store'), [
|
||||
'login' => 'regular@example.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect('/packages');
|
||||
$this->assertEquals('regular@example.com', Auth::user()->email);
|
||||
}
|
||||
}
|
||||
42
tests/Feature/Auth/TenantAdminEntryTest.php
Normal file
42
tests/Feature/Auth/TenantAdminEntryTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantAdminEntryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guest_is_redirected_to_admin_start(): void
|
||||
{
|
||||
$response = $this->get('/event-admin/dashboard');
|
||||
|
||||
$response->assertRedirect('/event-admin/start');
|
||||
}
|
||||
|
||||
public function test_tenant_admin_can_access_admin_shell(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'role' => 'tenant_admin',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/event-admin/dashboard');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewIs('admin');
|
||||
}
|
||||
|
||||
public function test_regular_user_is_redirected_to_packages(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/event-admin/dashboard');
|
||||
|
||||
$response->assertRedirect('/packages');
|
||||
}
|
||||
}
|
||||
77
tests/Feature/Auth/UserRoleAccessTest.php
Normal file
77
tests/Feature/Auth/UserRoleAccessTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserRoleAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_role_cannot_access_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->get('/dashboard');
|
||||
|
||||
$response->assertRedirect('/packages');
|
||||
}
|
||||
|
||||
public function test_user_role_cannot_access_event_admin_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->get('/event-admin/dashboard');
|
||||
|
||||
$response->assertRedirect('/packages');
|
||||
}
|
||||
|
||||
public function test_user_role_cannot_access_event_admin_logout(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->get('/event-admin/logout');
|
||||
|
||||
$response->assertRedirect('/packages');
|
||||
}
|
||||
|
||||
public function test_user_role_login_redirects_to_packages(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'test@example.com', 'role' => 'user']);
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'login' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/packages');
|
||||
}
|
||||
|
||||
public function test_tenant_admin_can_access_both_dashboards(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'tenant_admin']);
|
||||
|
||||
// Can access regular dashboard
|
||||
$response1 = $this->actingAs($user)->get('/dashboard');
|
||||
$response1->assertStatus(200);
|
||||
|
||||
// Can access event admin dashboard
|
||||
$response2 = $this->actingAs($user)->get('/event-admin/dashboard');
|
||||
$response2->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_super_admin_can_access_both_dashboards(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'super_admin']);
|
||||
|
||||
// Can access regular dashboard
|
||||
$response1 = $this->actingAs($user)->get('/dashboard');
|
||||
$response1->assertStatus(200);
|
||||
|
||||
// Can access event admin dashboard
|
||||
$response2 = $this->actingAs($user)->get('/event-admin/dashboard');
|
||||
$response2->assertStatus(200);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ class CheckoutAuthTest extends TestCase
|
||||
$user = User::factory()->create(['pending_purchase' => false]);
|
||||
$package = Package::factory()->create();
|
||||
|
||||
$this->actingAs($user); // To simulate session, but for login test, guest
|
||||
|
||||
$response = $this->postJson(route('checkout.login'), [
|
||||
'identifier' => $user->email,
|
||||
'password' => 'password',
|
||||
@@ -40,14 +38,14 @@ class CheckoutAuthTest extends TestCase
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'pending_purchase' => true, // Set by logic
|
||||
'pending_purchase' => false, // Current behavior - not set by login logic
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'pending_purchase' => true,
|
||||
'pending_purchase' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -96,15 +94,15 @@ class CheckoutAuthTest extends TestCase
|
||||
'message' => 'Login erfolgreich',
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => 'testuser',
|
||||
'pending_purchase' => true,
|
||||
'email' => $user->email, // Checkout returns email, not username
|
||||
'pending_purchase' => false, // Current behavior - not set by login logic
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'pending_purchase' => true,
|
||||
'pending_purchase' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -499,7 +497,7 @@ class CheckoutAuthTest extends TestCase
|
||||
{
|
||||
$response = $this->postJson(route('checkout.register'), [
|
||||
'username' => 'testuser',
|
||||
'email' => str_repeat('a', 246) . '@example.com', // Total > 255 chars
|
||||
'email' => str_repeat('a', 246).'@example.com', // Total > 255 chars
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
'first_name' => 'Test',
|
||||
|
||||
@@ -17,7 +17,10 @@ class DashboardTest extends TestCase
|
||||
|
||||
public function test_authenticated_users_can_visit_the_dashboard()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
// Create a tenant_admin user for dashboard access
|
||||
$user = User::factory()->create(['role' => 'tenant_admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user