fixes login page in tenant admin pwa

This commit is contained in:
Codex Agent
2025-11-07 13:52:29 +01:00
parent 253239455b
commit f3c44be76d
9 changed files with 156 additions and 21 deletions

View File

@@ -8,6 +8,7 @@ use App\Http\Requests\Tenant\SettingsStoreRequest;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Packages\TenantNotificationPreferences; use App\Services\Packages\TenantNotificationPreferences;
use App\Support\ApiError; use App\Support\ApiError;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -19,7 +20,7 @@ class SettingsController extends Controller
*/ */
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$tenant = $request->tenant; $tenant = $this->resolveTenant($request);
return response()->json([ return response()->json([
'message' => 'Settings erfolgreich abgerufen.', 'message' => 'Settings erfolgreich abgerufen.',
@@ -35,7 +36,7 @@ class SettingsController extends Controller
Request $request, Request $request,
TenantNotificationPreferences $preferencesService TenantNotificationPreferences $preferencesService
): JsonResponse { ): JsonResponse {
$tenant = $request->tenant; $tenant = $this->resolveTenant($request);
$defaults = TenantNotificationPreferences::defaults(); $defaults = TenantNotificationPreferences::defaults();
$resolved = []; $resolved = [];
@@ -60,7 +61,7 @@ class SettingsController extends Controller
NotificationPreferencesRequest $request, NotificationPreferencesRequest $request,
TenantNotificationPreferences $preferencesService TenantNotificationPreferences $preferencesService
): JsonResponse { ): JsonResponse {
$tenant = $request->tenant; $tenant = $this->resolveTenant($request);
$payload = $request->validated()['preferences']; $payload = $request->validated()['preferences'];
$tenant->update([ $tenant->update([
@@ -92,7 +93,7 @@ class SettingsController extends Controller
*/ */
public function update(SettingsStoreRequest $request): JsonResponse public function update(SettingsStoreRequest $request): JsonResponse
{ {
$tenant = $request->tenant; $tenant = $this->resolveTenant($request);
$settings = $request->validated()['settings']; $settings = $request->validated()['settings'];
$tenant->update([ $tenant->update([
@@ -115,7 +116,7 @@ class SettingsController extends Controller
*/ */
public function reset(Request $request): JsonResponse public function reset(Request $request): JsonResponse
{ {
$tenant = $request->tenant; $tenant = $this->resolveTenant($request);
$defaultSettings = [ $defaultSettings = [
'branding' => [ '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. * Validate custom domain availability.
*/ */

View File

@@ -8,6 +8,7 @@ use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; 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 */ /** @var User|null $user */
$user = Auth::guard('web')->user(); $user = Auth::guard('web')->user();
if (! $user) { if (! $user) {
return response()->json([ return response()->noContent();
'error' => 'unauthenticated',
'message' => trans('auth.failed'),
], 401);
} }
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {

View File

@@ -51,6 +51,10 @@ async function exchangeSessionForToken(): Promise<{ token: string; abilities: st
credentials: 'same-origin', credentials: 'same-origin',
}); });
if (response.status === 204) {
return null;
}
if (!response.ok) { if (!response.ok) {
return null; return null;
} }

View File

@@ -92,7 +92,7 @@ export function DevTenantSwitcher() {
</button> </button>
</div> </div>
<p className="text-xs text-amber-700"> <p className="text-xs text-amber-700">
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.
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
{DEV_TENANT_KEYS.map(({ key, label }) => ( {DEV_TENANT_KEYS.map(({ key, label }) => (

View File

@@ -10,11 +10,11 @@
"Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.", "Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.",
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus." "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_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_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", "cta": "Mit Fotospiel-Login fortfahren",
"google_cta": "Mit Google anmelden", "google_cta": "Mit Google anmelden",
"open_account_login": "Konto-Login öffnen", "open_account_login": "Konto-Login öffnen",

View File

@@ -10,11 +10,11 @@
"Create invites with personalized QR codes and share them instantly.", "Create invites with personalized QR codes and share them instantly.",
"Run tasks, emotions, and slideshows right from the event dashboard." "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_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_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", "cta": "Continue with Fotospiel login",
"google_cta": "Continue with Google", "google_cta": "Continue with Google",
"open_account_login": "Open account login", "open_account_login": "Open account login",

View File

@@ -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<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
@@ -164,11 +166,21 @@ export default function LoginPage(): JSX.Element {
<Button <Button
type="submit" 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]" 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} isSubmitting ? 'cursor-wait opacity-90 saturate-75 shadow-none' : ''
}`}
disabled={isSubmitting}
aria-live="polite"
aria-busy={isSubmitting}
data-loading={isSubmitting ? 'true' : undefined}
> >
{mutation.isLoading ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : null} <span className="flex w-full items-center justify-center gap-2">
{mutation.isLoading ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')} <Loader2
className={`h-5 w-5 text-white transition-opacity ${isSubmitting ? 'animate-spin opacity-100' : 'opacity-0'}`}
aria-hidden={!isSubmitting}
/>
<span>{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}</span>
</span>
</Button> </Button>
</form> </form>

View File

@@ -95,4 +95,31 @@ class TenantProfileApiTest extends TestCase
$response->assertStatus(401); $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'],
]);
}
} }

View File

@@ -0,0 +1,64 @@
<?php
namespace Tests\Feature\Tenant\Settings;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class NotificationPreferencesTest extends TestCase
{
use RefreshDatabase;
public function test_tenant_admin_can_fetch_notification_preferences(): void
{
$tenant = Tenant::factory()->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);
}
}