diff --git a/app/Http/Controllers/Api/Tenant/SettingsController.php b/app/Http/Controllers/Api/Tenant/SettingsController.php
index 7bfab31..6d65da5 100644
--- a/app/Http/Controllers/Api/Tenant/SettingsController.php
+++ b/app/Http/Controllers/Api/Tenant/SettingsController.php
@@ -8,6 +8,7 @@ use App\Http\Requests\Tenant\SettingsStoreRequest;
use App\Models\Tenant;
use App\Services\Packages\TenantNotificationPreferences;
use App\Support\ApiError;
+use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -19,7 +20,7 @@ class SettingsController extends Controller
*/
public function index(Request $request): JsonResponse
{
- $tenant = $request->tenant;
+ $tenant = $this->resolveTenant($request);
return response()->json([
'message' => 'Settings erfolgreich abgerufen.',
@@ -35,7 +36,7 @@ class SettingsController extends Controller
Request $request,
TenantNotificationPreferences $preferencesService
): JsonResponse {
- $tenant = $request->tenant;
+ $tenant = $this->resolveTenant($request);
$defaults = TenantNotificationPreferences::defaults();
$resolved = [];
@@ -60,7 +61,7 @@ class SettingsController extends Controller
NotificationPreferencesRequest $request,
TenantNotificationPreferences $preferencesService
): JsonResponse {
- $tenant = $request->tenant;
+ $tenant = $this->resolveTenant($request);
$payload = $request->validated()['preferences'];
$tenant->update([
@@ -92,7 +93,7 @@ class SettingsController extends Controller
*/
public function update(SettingsStoreRequest $request): JsonResponse
{
- $tenant = $request->tenant;
+ $tenant = $this->resolveTenant($request);
$settings = $request->validated()['settings'];
$tenant->update([
@@ -115,7 +116,7 @@ class SettingsController extends Controller
*/
public function reset(Request $request): JsonResponse
{
- $tenant = $request->tenant;
+ $tenant = $this->resolveTenant($request);
$defaultSettings = [
'branding' => [
@@ -150,6 +151,35 @@ class SettingsController extends Controller
]);
}
+ private function resolveTenant(Request $request): Tenant
+ {
+ $tenant = $request->attributes->get('tenant');
+
+ if ($tenant instanceof Tenant) {
+ return $tenant;
+ }
+
+ $tenantId = $request->attributes->get('tenant_id')
+ ?? $request->attributes->get('current_tenant_id')
+ ?? $request->user()?->tenant_id;
+
+ if ($tenantId) {
+ $tenant = Tenant::query()->find($tenantId);
+ if ($tenant) {
+ $request->attributes->set('tenant', $tenant);
+
+ return $tenant;
+ }
+ }
+
+ throw new HttpResponseException(ApiError::response(
+ 'tenant_context_missing',
+ 'Tenant context missing',
+ 'Unable to determine tenant for the current request.',
+ Response::HTTP_UNAUTHORIZED
+ ));
+ }
+
/**
* Validate custom domain availability.
*/
diff --git a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php
index 8470c51..9088664 100644
--- a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php
+++ b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php
@@ -8,6 +8,7 @@ use App\Models\Tenant;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -152,16 +153,13 @@ class TenantAdminTokenController extends Controller
]);
}
- public function exchange(Request $request): JsonResponse
+ public function exchange(Request $request): JsonResponse|Response
{
/** @var User|null $user */
$user = Auth::guard('web')->user();
if (! $user) {
- return response()->json([
- 'error' => 'unauthenticated',
- 'message' => trans('auth.failed'),
- ], 401);
+ return response()->noContent();
}
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
diff --git a/resources/js/admin/auth/context.tsx b/resources/js/admin/auth/context.tsx
index 3c68580..14ffeb5 100644
--- a/resources/js/admin/auth/context.tsx
+++ b/resources/js/admin/auth/context.tsx
@@ -51,6 +51,10 @@ async function exchangeSessionForToken(): Promise<{ token: string; abilities: st
credentials: 'same-origin',
});
+ if (response.status === 204) {
+ return null;
+ }
+
if (!response.ok) {
return null;
}
diff --git a/resources/js/admin/components/DevTenantSwitcher.tsx b/resources/js/admin/components/DevTenantSwitcher.tsx
index 7ef9622..e6d6d40 100644
--- a/resources/js/admin/components/DevTenantSwitcher.tsx
+++ b/resources/js/admin/components/DevTenantSwitcher.tsx
@@ -92,7 +92,7 @@ export function DevTenantSwitcher() {
- Select a seeded tenant to mint OAuth tokens and jump straight into their admin space. Available only in development builds.
+ Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds.
{DEV_TENANT_KEYS.map(({ key, label }) => (
diff --git a/resources/js/admin/i18n/locales/de/auth.json b/resources/js/admin/i18n/locales/de/auth.json
index 036b3a5..6bc266a 100644
--- a/resources/js/admin/i18n/locales/de/auth.json
+++ b/resources/js/admin/i18n/locales/de/auth.json
@@ -10,11 +10,11 @@
"Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.",
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
],
- "lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.",
+ "lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.",
"panel_title": "Melde dich an",
- "panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit OAuth 2.1 und klaren Rollenrechten.",
+ "panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit persönlichen Zugriffstokens und klaren Rollenrechten.",
"actions_title": "Wähle deine Anmeldemethode",
- "actions_copy": "Greife sicher per OAuth oder mit deinem Google-Konto auf das Tenant-Dashboard zu.",
+ "actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Tenant-Dashboard zu.",
"cta": "Mit Fotospiel-Login fortfahren",
"google_cta": "Mit Google anmelden",
"open_account_login": "Konto-Login öffnen",
diff --git a/resources/js/admin/i18n/locales/en/auth.json b/resources/js/admin/i18n/locales/en/auth.json
index 1a5dad3..def7bc5 100644
--- a/resources/js/admin/i18n/locales/en/auth.json
+++ b/resources/js/admin/i18n/locales/en/auth.json
@@ -10,11 +10,11 @@
"Create invites with personalized QR codes and share them instantly.",
"Run tasks, emotions, and slideshows right from the event dashboard."
],
- "lead": "Use our secure OAuth login and land directly in the event dashboard.",
+ "lead": "Use our secure Fotospiel login and land directly in the event dashboard.",
"panel_title": "Sign in",
- "panel_copy": "Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.",
+ "panel_copy": "Sign in with your Fotospiel admin access. Sanctum personal access tokens and clear role permissions keep your account protected.",
"actions_title": "Choose your sign-in method",
- "actions_copy": "Access the tenant dashboard securely with OAuth or your Google account.",
+ "actions_copy": "Access the tenant dashboard securely with your Fotospiel login or your Google account.",
"cta": "Continue with Fotospiel login",
"google_cta": "Continue with Google",
"open_account_login": "Open account login",
diff --git a/resources/js/admin/pages/LoginPage.tsx b/resources/js/admin/pages/LoginPage.tsx
index 0bc4075..8fd15be 100644
--- a/resources/js/admin/pages/LoginPage.tsx
+++ b/resources/js/admin/pages/LoginPage.tsx
@@ -86,6 +86,8 @@ export default function LoginPage(): JSX.Element {
},
});
+ const isSubmitting = (mutation as { isPending?: boolean; isLoading: boolean }).isPending ?? mutation.isLoading;
+
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
setError(null);
@@ -164,11 +166,21 @@ export default function LoginPage(): JSX.Element {
diff --git a/tests/Feature/Auth/TenantProfileApiTest.php b/tests/Feature/Auth/TenantProfileApiTest.php
index f24947e..1906c69 100644
--- a/tests/Feature/Auth/TenantProfileApiTest.php
+++ b/tests/Feature/Auth/TenantProfileApiTest.php
@@ -95,4 +95,31 @@ class TenantProfileApiTest extends TestCase
$response->assertStatus(401);
}
+
+ public function test_exchange_returns_no_content_when_session_missing(): void
+ {
+ $response = $this->postJson('/api/v1/tenant-auth/exchange');
+
+ $response->assertNoContent();
+ }
+
+ public function test_exchange_returns_token_for_authenticated_session(): void
+ {
+ $tenant = Tenant::factory()->create();
+ $user = User::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'role' => 'tenant_admin',
+ 'email_verified_at' => now(),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/api/v1/tenant-auth/exchange');
+
+ $response->assertOk();
+ $response->assertJsonStructure([
+ 'token',
+ 'token_type',
+ 'abilities',
+ 'user' => ['id', 'email', 'role', 'tenant_id'],
+ ]);
+ }
}
diff --git a/tests/Feature/Tenant/Settings/NotificationPreferencesTest.php b/tests/Feature/Tenant/Settings/NotificationPreferencesTest.php
new file mode 100644
index 0000000..20c96bd
--- /dev/null
+++ b/tests/Feature/Tenant/Settings/NotificationPreferencesTest.php
@@ -0,0 +1,64 @@
+create([
+ 'notification_preferences' => [
+ 'photo_thresholds' => false,
+ ],
+ ]);
+
+ $user = User::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'role' => 'tenant_admin',
+ 'email_verified_at' => now(),
+ ]);
+
+ Sanctum::actingAs($user, abilities: ['tenant-admin', 'tenant:'.$tenant->id]);
+
+ $response = $this->getJson('/api/v1/tenant/settings/notifications');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.preferences.photo_thresholds', false);
+ $response->assertJsonStructure([
+ 'data' => [
+ 'defaults',
+ 'preferences',
+ 'overrides',
+ 'meta' => ['credit_warning_sent_at', 'credit_warning_threshold'],
+ ],
+ ]);
+ }
+
+ public function test_super_admin_can_fetch_notification_preferences_for_specific_tenant(): void
+ {
+ $tenant = Tenant::factory()->create();
+
+ $superAdmin = User::factory()->create([
+ 'role' => 'super_admin',
+ 'tenant_id' => null,
+ 'email_verified_at' => now(),
+ ]);
+
+ Sanctum::actingAs($superAdmin, abilities: ['tenant-admin', 'super-admin']);
+
+ $response = $this
+ ->withHeader('X-Tenant-ID', (string) $tenant->id)
+ ->getJson('/api/v1/tenant/settings/notifications');
+
+ $response->assertOk();
+ $response->assertJsonPath('data.defaults.photo_thresholds', true);
+ }
+}