fixes login page in tenant admin pwa
This commit is contained in:
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user