From 73728f6bafbc9671a56e56817ca6aaa63457ba40 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 23 Jan 2026 20:19:15 +0100 Subject: [PATCH] Add Facebook social login --- .env.example | 5 + app/Http/Controllers/CheckoutController.php | 8 + .../CheckoutFacebookController.php | 216 ++++++++++++++++++ .../TenantAdminFacebookController.php | 111 +++++++++ config/services.php | 5 + lang/de/checkout.php | 2 + lang/en/checkout.php | 2 + public/lang/de/auth.json | 2 + public/lang/de/marketing.json | 6 + public/lang/en/auth.json | 2 + public/lang/en/marketing.json | 6 + resources/js/admin/i18n/locales/de/auth.json | 7 +- resources/js/admin/i18n/locales/en/auth.json | 7 +- resources/js/admin/mobile/LoginPage.tsx | 54 +++++ .../admin/mobile/__tests__/LoginPage.test.tsx | 4 +- resources/js/pages/auth/LoginForm.tsx | 86 +++++-- resources/js/pages/auth/RegisterForm.tsx | 10 +- .../pages/auth/__tests__/LoginForm.test.tsx | 6 +- resources/js/pages/auth/login.tsx | 51 +++++ .../js/pages/marketing/CheckoutWizardPage.tsx | 12 +- .../marketing/checkout/CheckoutWizard.tsx | 75 ++++-- .../__tests__/AuthStep.google.test.tsx | 8 +- .../marketing/checkout/steps/AuthStep.tsx | 138 ++++++++--- .../js/pages/marketing/checkout/types.ts | 2 +- resources/lang/de/auth.json | 2 + resources/lang/en/auth.json | 2 + routes/web.php | 8 + .../TenantAdminFacebookControllerTest.php | 130 +++++++++++ .../CheckoutFacebookControllerTest.php | 112 +++++++++ 29 files changed, 991 insertions(+), 88 deletions(-) create mode 100644 app/Http/Controllers/CheckoutFacebookController.php create mode 100644 app/Http/Controllers/TenantAdminFacebookController.php create mode 100644 tests/Feature/Auth/TenantAdminFacebookControllerTest.php create mode 100644 tests/Feature/CheckoutFacebookControllerTest.php diff --git a/.env.example b/.env.example index 03b9d8d..425dd5d 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,11 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback +# Facebook OAuth (Checkout comfort login) +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= +FACEBOOK_REDIRECT_URI=${APP_URL}/checkout/auth/facebook/callback + VITE_APP_NAME="${APP_NAME}" VITE_ENABLE_TENANT_SWITCHER=false REVENUECAT_WEBHOOK_SECRET= diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 5ed0382..b6a1086 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -48,6 +48,9 @@ class CheckoutController extends Controller $googleStatus = session()->pull('checkout_google_status'); $googleError = session()->pull('checkout_google_error'); $googleProfile = session()->pull('checkout_google_profile'); + $facebookStatus = session()->pull('checkout_facebook_status'); + $facebookError = session()->pull('checkout_facebook_error'); + $facebookProfile = session()->pull('checkout_facebook_profile'); $packageOptions = Package::orderBy('price')->get() ->map(fn (Package $pkg) => $this->presentPackage($pkg)) @@ -66,6 +69,11 @@ class CheckoutController extends Controller 'error' => $googleError, 'profile' => $googleProfile, ], + 'facebookAuth' => [ + 'status' => $facebookStatus, + 'error' => $facebookError, + 'profile' => $facebookProfile, + ], 'paddle' => [ 'environment' => config('paddle.environment'), 'client_token' => config('paddle.client_token'), diff --git a/app/Http/Controllers/CheckoutFacebookController.php b/app/Http/Controllers/CheckoutFacebookController.php new file mode 100644 index 0000000..4871ddd --- /dev/null +++ b/app/Http/Controllers/CheckoutFacebookController.php @@ -0,0 +1,216 @@ +validate([ + 'package_id' => ['required', 'exists:packages,id'], + 'locale' => ['nullable', 'string'], + ]); + + $payload = [ + 'package_id' => (int) $validated['package_id'], + 'locale' => $validated['locale'] ?? app()->getLocale(), + ]; + + $request->session()->put(self::SESSION_KEY, $payload); + $request->session()->put('selected_package_id', $payload['package_id']); + + return Socialite::driver('facebook') + ->scopes(['email']) + ->fields(['name', 'email', 'first_name', 'last_name']) + ->redirect(); + } + + public function callback(Request $request): RedirectResponse + { + $payload = $request->session()->get(self::SESSION_KEY, []); + $packageId = $payload['package_id'] ?? null; + $locale = $payload['locale'] ?? null; + + try { + $facebookUser = Socialite::driver('facebook')->user(); + } catch (\Throwable $e) { + Log::warning('Facebook checkout login failed', ['message' => $e->getMessage()]); + $this->flashError($request, __('checkout.facebook_error_fallback')); + + return $this->redirectBackToWizard($packageId, $locale); + } + + $email = $facebookUser->getEmail(); + if (! $email) { + $this->flashError($request, __('checkout.facebook_missing_email')); + + return $this->redirectBackToWizard($packageId, $locale); + } + + $raw = $facebookUser->getRaw(); + $givenName = $raw['first_name'] ?? null; + $familyName = $raw['last_name'] ?? null; + $request->session()->put('checkout_facebook_profile', array_filter([ + 'email' => $email, + 'name' => $facebookUser->getName(), + 'given_name' => $givenName, + 'family_name' => $familyName, + 'avatar' => $facebookUser->getAvatar(), + 'locale' => $raw['locale'] ?? null, + ])); + + $existing = User::where('email', $email)->first(); + + if (! $existing) { + $request->session()->put('checkout_facebook_profile', array_filter([ + 'email' => $email, + 'name' => $facebookUser->getName(), + 'given_name' => $givenName, + 'family_name' => $familyName, + 'avatar' => $facebookUser->getAvatar(), + 'locale' => $raw['locale'] ?? null, + ])); + + $request->session()->put('checkout_facebook_status', 'prefill'); + + return $this->redirectBackToWizard($packageId, $locale); + } + + $user = DB::transaction(function () use ($existing, $facebookUser, $email) { + $existing->forceFill([ + 'name' => $facebookUser->getName() ?: $existing->name, + 'pending_purchase' => true, + 'email_verified_at' => $existing->email_verified_at ?? now(), + ])->save(); + + if (! $existing->tenant) { + $this->createTenantForUser($existing, $facebookUser->getName(), $email); + } + + return $existing->fresh(); + }); + + if (! $user->tenant) { + $this->createTenantForUser($user, $facebookUser->getName(), $email); + } + + Auth::login($user, true); + $request->session()->regenerate(); + $request->session()->forget(self::SESSION_KEY); + $request->session()->forget('checkout_facebook_profile'); + $request->session()->put('checkout_facebook_status', 'signin'); + + if ($packageId) { + $this->ensurePackageAttached($user, (int) $packageId); + } + + return $this->redirectBackToWizard($packageId, $locale); + } + + private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant + { + $tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant'; + $slugBase = Str::slug($tenantName) ?: 'tenant'; + $slug = $slugBase; + $counter = 1; + + while (Tenant::where('slug', $slug)->exists()) { + $slug = $slugBase.'-'.$counter; + $counter++; + } + + $tenant = Tenant::create([ + 'user_id' => $user->id, + 'name' => $tenantName, + 'slug' => $slug, + 'email' => $email, + 'contact_email' => $email, + 'is_active' => true, + 'is_suspended' => false, + 'subscription_tier' => 'free', + 'subscription_status' => 'free', + 'subscription_expires_at' => null, + 'settings' => json_encode([ + 'branding' => [ + 'logo_url' => null, + 'primary_color' => '#FF5A5F', + 'secondary_color' => '#FFF8F5', + 'font_family' => 'Inter, sans-serif', + ], + 'features' => [ + 'photo_likes_enabled' => false, + 'event_checklist' => false, + 'custom_domain' => false, + 'advanced_analytics' => false, + ], + 'custom_domain' => null, + 'contact_email' => $email, + 'event_default_type' => 'general', + ]), + ]); + + $user->forceFill(['tenant_id' => $tenant->id])->save(); + + return $tenant; + } + + private function ensurePackageAttached(User $user, int $packageId): void + { + $tenant = $user->tenant; + if (! $tenant) { + return; + } + + $package = Package::find($packageId); + if (! $package) { + return; + } + + if ($tenant->packages()->where('package_id', $packageId)->exists()) { + return; + } + + $tenant->packages()->attach($packageId, [ + 'price' => $package->price, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + 'active' => $package->price <= 0, + ]); + } + + private function redirectBackToWizard(?int $packageId, ?string $locale = null): RedirectResponse + { + if ($packageId) { + return redirect()->to(CheckoutRoutes::wizardUrl($packageId, $locale)); + } + + $firstPackageId = Package::query()->orderBy('price')->value('id'); + if ($firstPackageId) { + return redirect()->to(CheckoutRoutes::wizardUrl($firstPackageId, $locale)); + } + + return redirect()->route('packages', [ + 'locale' => LocaleConfig::canonicalize($locale ?? app()->getLocale()), + ]); + } + + private function flashError(Request $request, string $message): void + { + $request->session()->flash('checkout_facebook_error', $message); + } +} diff --git a/app/Http/Controllers/TenantAdminFacebookController.php b/app/Http/Controllers/TenantAdminFacebookController.php new file mode 100644 index 0000000..0aee012 --- /dev/null +++ b/app/Http/Controllers/TenantAdminFacebookController.php @@ -0,0 +1,111 @@ +query('return_to'); + if (is_string($returnTo) && $returnTo !== '') { + $request->session()->put('tenant_oauth_return_to', $returnTo); + } + + return Socialite::driver('facebook') + ->scopes(['email']) + ->fields(['name', 'email', 'first_name', 'last_name']) + ->redirect(); + } + + public function callback(Request $request): RedirectResponse + { + try { + $facebookUser = Socialite::driver('facebook')->user(); + } catch (Throwable $exception) { + Log::warning('Tenant admin Facebook sign-in failed', [ + 'message' => $exception->getMessage(), + ]); + + return $this->sendBackWithError($request, 'facebook_failed', 'Unable to complete Facebook sign-in.'); + } + + $email = $facebookUser->getEmail(); + if (! $email) { + return $this->sendBackWithError($request, 'facebook_failed', 'Facebook account did not provide an email address.'); + } + + /** @var User|null $user */ + $user = User::query()->where('email', $email)->first(); + + if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) { + return $this->sendBackWithError($request, 'facebook_no_match', 'No tenant admin account is linked to this Facebook address.'); + } + + $user->forceFill([ + 'name' => $facebookUser->getName() ?: $user->name, + 'email_verified_at' => $user->email_verified_at ?? now(), + ])->save(); + + Auth::login($user, true); + $request->session()->regenerate(); + $request->session()->forget('url.intended'); + + $returnTo = $request->session()->pull('tenant_oauth_return_to'); + if (is_string($returnTo)) { + $decoded = $this->decodeReturnTo($returnTo, $request); + if ($decoded) { + return redirect()->to($decoded); + } + } + + $fallback = $request->session()->pull('tenant_admin.return_to'); + if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) { + return redirect()->to($fallback); + } + + return redirect()->to('/event-admin/dashboard'); + } + + private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse + { + $query = [ + 'error' => $code, + 'error_description' => $message, + ]; + + if ($request->session()->has('tenant_oauth_return_to')) { + $query['return_to'] = $request->session()->get('tenant_oauth_return_to'); + } + + return redirect()->route('tenant.admin.login', $query); + } + + private function decodeReturnTo(string $encoded, Request $request): ?string + { + $padded = str_pad($encoded, strlen($encoded) + ((4 - (strlen($encoded) % 4)) % 4), '='); + $normalized = strtr($padded, '-_', '+/'); + $decoded = base64_decode($normalized); + + if (! is_string($decoded) || $decoded === '') { + return null; + } + + $targetHost = parse_url($decoded, PHP_URL_HOST); + $appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST); + + if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) { + return null; + } + + return $decoded; + } +} diff --git a/config/services.php b/config/services.php index 5d6573a..c512dc6 100644 --- a/config/services.php +++ b/config/services.php @@ -57,6 +57,11 @@ return [ 'client_secret' => env('GOOGLE_CLIENT_SECRET'), 'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/google/callback'), ], + 'facebook' => [ + 'client_id' => env('FACEBOOK_CLIENT_ID'), + 'client_secret' => env('FACEBOOK_CLIENT_SECRET'), + 'redirect' => env('FACEBOOK_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/facebook/callback'), + ], 'matomo' => [ 'enabled' => env('MATOMO_ENABLED', false), diff --git a/lang/de/checkout.php b/lang/de/checkout.php index 38f4347..a185918 100644 --- a/lang/de/checkout.php +++ b/lang/de/checkout.php @@ -3,4 +3,6 @@ return [ 'google_error_fallback' => 'Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.', 'google_missing_email' => 'Wir konnten deine Google-E-Mail-Adresse nicht abrufen.', + 'facebook_error_fallback' => 'Die Facebook-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.', + 'facebook_missing_email' => 'Wir konnten deine Facebook-E-Mail-Adresse nicht abrufen.', ]; diff --git a/lang/en/checkout.php b/lang/en/checkout.php index 77ccce7..b5c8b77 100644 --- a/lang/en/checkout.php +++ b/lang/en/checkout.php @@ -3,4 +3,6 @@ return [ 'google_error_fallback' => 'We could not complete the Google login. Please try again.', 'google_missing_email' => 'We could not retrieve your Google email address.', + 'facebook_error_fallback' => 'We could not complete the Facebook login. Please try again.', + 'facebook_missing_email' => 'We could not retrieve your Facebook email address.', ]; diff --git a/public/lang/de/auth.json b/public/lang/de/auth.json index d8521a6..4f2b8d1 100644 --- a/public/lang/de/auth.json +++ b/public/lang/de/auth.json @@ -61,6 +61,8 @@ "oauth_divider": "oder", "google_cta": "Mit Google anmelden", "google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.", + "facebook_cta": "Mit Facebook anmelden", + "facebook_helper": "Nutze dein Facebook-Konto, um dich sicher bei der Eventverwaltung anzumelden.", "no_account": "Noch keinen Zugang?", "sign_up": "Jetzt registrieren" }, diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index b3edcaa..d4c0fe1 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -648,11 +648,17 @@ "switch_to_register": "Registrieren", "switch_to_login": "Anmelden", "continue_with_google": "Mit Google fortfahren", + "continue_with_facebook": "Mit Facebook fortfahren", "google_success_toast": "Mit Google angemeldet.", "google_error_title": "Google-Anmeldung fehlgeschlagen", "google_missing_package": "Bitte wähle zuerst ein Paket aus, bevor du Google Login nutzt.", "google_missing_email": "Wir konnten deine Google-E-Mail-Adresse nicht abrufen.", "google_error_fallback": "Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.", + "facebook_success_toast": "Mit Facebook angemeldet.", + "facebook_error_title": "Facebook-Anmeldung fehlgeschlagen", + "facebook_missing_package": "Bitte wähle zuerst ein Paket aus, bevor du Facebook Login nutzt.", + "facebook_missing_email": "Wir konnten deine Facebook-E-Mail-Adresse nicht abrufen.", + "facebook_error_fallback": "Die Facebook-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.", "google_helper": "Schneller Login über Google – deine Daten werden ausschließlich zur Kontoeinrichtung verwendet.", "google_helper_badge": "Warum Google?" }, diff --git a/public/lang/en/auth.json b/public/lang/en/auth.json index 02cefe4..2aeb39b 100644 --- a/public/lang/en/auth.json +++ b/public/lang/en/auth.json @@ -61,6 +61,8 @@ "oauth_divider": "or", "google_cta": "Continue with Google", "google_helper": "Use your Google account to access the event dashboard securely.", + "facebook_cta": "Continue with Facebook", + "facebook_helper": "Use your Facebook account to access the event dashboard securely.", "no_account": "Don't have access yet?", "sign_up": "Create an account" }, diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 662871e..92f6db8 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -645,11 +645,17 @@ "switch_to_register": "Register", "switch_to_login": "Login", "continue_with_google": "Continue with Google", + "continue_with_facebook": "Continue with Facebook", "google_success_toast": "Signed in with Google.", "google_error_title": "Google login failed", "google_missing_package": "Please choose a package before using Google login.", "google_missing_email": "We could not retrieve your Google email address.", "google_error_fallback": "We couldn't complete the Google login. Please try again.", + "facebook_success_toast": "Signed in with Facebook.", + "facebook_error_title": "Facebook login failed", + "facebook_missing_package": "Please choose a package before using Facebook login.", + "facebook_missing_email": "We could not retrieve your Facebook email address.", + "facebook_error_fallback": "We couldn't complete the Facebook login. Please try again.", "google_helper": "Sign in faster with Google – we only use your details to create your Fotospiel account.", "google_helper_badge": "Why Google?" }, diff --git a/resources/js/admin/i18n/locales/de/auth.json b/resources/js/admin/i18n/locales/de/auth.json index c26411a..7eb4a8e 100644 --- a/resources/js/admin/i18n/locales/de/auth.json +++ b/resources/js/admin/i18n/locales/de/auth.json @@ -39,9 +39,10 @@ "missing_token": "Reset-Token fehlt." }, "actions_title": "Wähle deine Anmeldemethode", - "actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Kunden-Dashboard zu.", + "actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google- oder Facebook-Konto auf das Kunden-Dashboard zu.", "cta": "Mit Fotospiel-Login fortfahren", "google_cta": "Mit Google anmelden", + "facebook_cta": "Mit Facebook anmelden", "open_account_login": "Konto-Login öffnen", "loading": "Bitte warten …", "oauth_error_title": "Login aktuell nicht möglich", @@ -54,7 +55,9 @@ "invalid_scope": "Die App fordert Berechtigungen an, die nicht freigegeben sind.", "tenant_mismatch": "Du hast keinen Zugriff auf das Kundenkonto, das diese Anmeldung angefordert hat.", "google_failed": "Die Anmeldung mit Google war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.", - "google_no_match": "Wir konnten dieses Google-Konto keinem Kunden-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an." + "google_no_match": "Wir konnten dieses Google-Konto keinem Kunden-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an.", + "facebook_failed": "Die Anmeldung mit Facebook war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.", + "facebook_no_match": "Wir konnten dieses Facebook-Konto keinem Kunden-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an." }, "return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.", "support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.", diff --git a/resources/js/admin/i18n/locales/en/auth.json b/resources/js/admin/i18n/locales/en/auth.json index 65af231..f9053ca 100644 --- a/resources/js/admin/i18n/locales/en/auth.json +++ b/resources/js/admin/i18n/locales/en/auth.json @@ -39,9 +39,10 @@ "missing_token": "Reset token missing." }, "actions_title": "Choose your sign-in method", - "actions_copy": "Access the customer dashboard securely with your Fotospiel login or your Google account.", + "actions_copy": "Access the customer dashboard securely with your Fotospiel login or your Google or Facebook account.", "cta": "Continue with Fotospiel login", "google_cta": "Continue with Google", + "facebook_cta": "Continue with Facebook", "open_account_login": "Open account login", "loading": "Signing you in …", "oauth_error_title": "Login not possible right now", @@ -54,7 +55,9 @@ "invalid_scope": "The app asked for permissions it cannot receive.", "tenant_mismatch": "You don’t have access to the customer account that requested this login.", "google_failed": "Google sign-in was not successful. Please try again or use another method.", - "google_no_match": "We couldn’t link this Google account to a customer admin. Please sign in with Fotospiel credentials." + "google_no_match": "We couldn’t link this Google account to a customer admin. Please sign in with Fotospiel credentials.", + "facebook_failed": "Facebook sign-in was not successful. Please try again or use another method.", + "facebook_no_match": "We couldn’t link this Facebook account to a customer admin. Please sign in with Fotospiel credentials." }, "return_hint": "After signing in you’ll be brought back automatically.", "support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.", diff --git a/resources/js/admin/mobile/LoginPage.tsx b/resources/js/admin/mobile/LoginPage.tsx index 96cb6c8..7527253 100644 --- a/resources/js/admin/mobile/LoginPage.tsx +++ b/resources/js/admin/mobile/LoginPage.tsx @@ -105,6 +105,16 @@ export default function MobileLoginPage() { return `/event-admin/auth/google?${params.toString()}`; }, [encodedFinal]); + const facebookHref = React.useMemo(() => { + if (!encodedFinal) { + return '/event-admin/auth/facebook'; + } + + const params = new URLSearchParams({ return_to: encodedFinal }); + + return `/event-admin/auth/facebook?${params.toString()}`; + }, [encodedFinal]); + React.useEffect(() => { if (status === 'authenticated') { navigate(finalTarget, { replace: true }); @@ -115,6 +125,7 @@ export default function MobileLoginPage() { const [password, setPassword] = React.useState(''); const [error, setError] = React.useState(null); const [isRedirectingToGoogle, setIsRedirectingToGoogle] = React.useState(false); + const [isRedirectingToFacebook, setIsRedirectingToFacebook] = React.useState(false); const [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed()); const installBanner = shouldShowInstallBanner( { @@ -170,6 +181,15 @@ export default function MobileLoginPage() { window.location.assign(googleHref); }, [googleHref]); + const handleFacebookLogin = React.useCallback(() => { + if (typeof window === 'undefined') { + return; + } + + setIsRedirectingToFacebook(true); + window.location.assign(facebookHref); + }, [facebookHref]); + return ( + + @@ -365,3 +408,14 @@ function GoogleIcon({ size = 18 }: { size?: number }) { ); } + +function FacebookIcon({ size = 18 }: { size?: number }) { + return ( + + + + ); +} diff --git a/resources/js/admin/mobile/__tests__/LoginPage.test.tsx b/resources/js/admin/mobile/__tests__/LoginPage.test.tsx index 63bec47..cdf1b14 100644 --- a/resources/js/admin/mobile/__tests__/LoginPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/LoginPage.test.tsx @@ -103,13 +103,15 @@ vi.mock('lucide-react', () => ({ import MobileLoginPage from '../LoginPage'; describe('MobileLoginPage', () => { - it('renders Google login button', () => { + it('renders OAuth login buttons', () => { locationSearch = '?return_to=encoded-value'; render(); const googleButton = screen.getByText('Mit Google anmelden'); expect(googleButton).toBeInTheDocument(); + const facebookButton = screen.getByText('Mit Facebook anmelden'); + expect(facebookButton).toBeInTheDocument(); }); it('shows oauth error details when present', () => { diff --git a/resources/js/pages/auth/LoginForm.tsx b/resources/js/pages/auth/LoginForm.tsx index b011885..dffb966 100644 --- a/resources/js/pages/auth/LoginForm.tsx +++ b/resources/js/pages/auth/LoginForm.tsx @@ -51,9 +51,9 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale, const loginEndpoint = '/checkout/login'; - const canUseGoogle = typeof packageId === "number" && !Number.isNaN(packageId); + const canUseOauth = typeof packageId === "number" && !Number.isNaN(packageId); const googleHref = useMemo(() => { - if (!canUseGoogle) { + if (!canUseOauth) { return ""; } @@ -63,7 +63,20 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale, }); return `/checkout/auth/google?${params.toString()}`; - }, [canUseGoogle, packageId, resolvedLocale]); + }, [canUseOauth, packageId, resolvedLocale]); + + const facebookHref = useMemo(() => { + if (!canUseOauth) { + return ""; + } + + const params = new URLSearchParams({ + package_id: String(packageId), + locale: resolvedLocale, + }); + + return `/checkout/auth/facebook?${params.toString()}`; + }, [canUseOauth, packageId, resolvedLocale]); const [values, setValues] = useState({ identifier: "", @@ -73,6 +86,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale, const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); + const [isRedirectingToFacebook, setIsRedirectingToFacebook] = useState(false); const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const [shouldFocusError, setShouldFocusError] = useState(false); @@ -188,6 +202,15 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale, window.location.href = googleHref; }; + const handleFacebookLogin = () => { + if (!facebookHref) { + return; + } + + setIsRedirectingToFacebook(true); + window.location.href = facebookHref; + }; + return (
@@ -243,27 +266,43 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale,
- {canUseGoogle && ( + {canUseOauth && (
{t("login.oauth_divider", "oder")}
- +
+ + +
)} @@ -286,3 +325,14 @@ function GoogleIcon({ className }: { className?: string }) { ); } + +function FacebookIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/resources/js/pages/auth/RegisterForm.tsx b/resources/js/pages/auth/RegisterForm.tsx index 015c837..416c688 100644 --- a/resources/js/pages/auth/RegisterForm.tsx +++ b/resources/js/pages/auth/RegisterForm.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import toast from 'react-hot-toast'; import { LoaderCircle, User, Mail, Lock } from 'lucide-react'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; -import type { GoogleProfilePrefill } from '../marketing/checkout/types'; +import type { OAuthProfilePrefill } from '../marketing/checkout/types'; export interface RegisterSuccessPayload { user: unknown | null; @@ -17,8 +17,8 @@ interface RegisterFormProps { onSuccess?: (payload: RegisterSuccessPayload) => void; privacyHtml: string; locale?: string; - prefill?: GoogleProfilePrefill; - onClearGoogleProfile?: () => void; + prefill?: OAuthProfilePrefill; + onClearPrefill?: () => void; } type RegisterFormFields = { @@ -50,7 +50,7 @@ const resolveMetaCsrfToken = (): string => { return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? ''; }; -export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) { +export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearPrefill }: RegisterFormProps) { const [privacyOpen, setPrivacyOpen] = useState(false); const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -176,7 +176,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale redirect: json?.redirect ?? null, pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false, }); - onClearGoogleProfile?.(); + onClearPrefill?.(); reset(); setHasTriedSubmit(false); return; diff --git a/resources/js/pages/auth/__tests__/LoginForm.test.tsx b/resources/js/pages/auth/__tests__/LoginForm.test.tsx index c943669..d6d08ac 100644 --- a/resources/js/pages/auth/__tests__/LoginForm.test.tsx +++ b/resources/js/pages/auth/__tests__/LoginForm.test.tsx @@ -39,15 +39,17 @@ vi.mock('react-hot-toast', () => ({ import LoginForm from '../LoginForm'; describe('LoginForm', () => { - it('renders Google login option when packageId is provided', () => { + it('renders OAuth login options when packageId is provided', () => { render(); expect(screen.getByText('login.google_cta')).toBeInTheDocument(); + expect(screen.getByText('login.facebook_cta')).toBeInTheDocument(); }); - it('does not render Google login option without packageId', () => { + it('does not render OAuth login options without packageId', () => { render(); expect(screen.queryByText('login.google_cta')).not.toBeInTheDocument(); + expect(screen.queryByText('login.facebook_cta')).not.toBeInTheDocument(); }); }); diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index b829223..63249a3 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -24,6 +24,7 @@ export default function Login({ status, canResetPassword }: LoginProps) { const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const [rawReturnTo, setRawReturnTo] = useState(null); const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); + const [isRedirectingToFacebook, setIsRedirectingToFacebook] = useState(false); const { t } = useTranslation('auth'); const page = usePage<{ flash?: { verification?: { status: string; title?: string; message?: string } } }>(); const verificationFlash = page.props.flash?.verification; @@ -98,6 +99,18 @@ export default function Login({ status, canResetPassword }: LoginProps) { return `/event-admin/auth/google?${params.toString()}`; }, [rawReturnTo]); + const facebookHref = useMemo(() => { + if (!rawReturnTo) { + return '/event-admin/auth/facebook'; + } + + const params = new URLSearchParams({ + return_to: rawReturnTo, + }); + + return `/event-admin/auth/facebook?${params.toString()}`; + }, [rawReturnTo]); + const handleGoogleLogin = () => { if (typeof window === 'undefined') { return; @@ -107,6 +120,15 @@ export default function Login({ status, canResetPassword }: LoginProps) { window.location.href = googleHref; }; + const handleFacebookLogin = () => { + if (typeof window === 'undefined') { + return; + } + + setIsRedirectingToFacebook(true); + window.location.href = facebookHref; + }; + return ( {t('login.google_cta')} + +

{t('login.google_helper', 'Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.')}

+

+ {t('login.facebook_helper', 'Melde dich schnell mit deinem Facebook-Konto an.')} +

@@ -315,3 +355,14 @@ function GoogleIcon({ className }: { className?: string }) { ); } + +function FacebookIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/resources/js/pages/marketing/CheckoutWizardPage.tsx b/resources/js/pages/marketing/CheckoutWizardPage.tsx index 9617d4e..6a025ee 100644 --- a/resources/js/pages/marketing/CheckoutWizardPage.tsx +++ b/resources/js/pages/marketing/CheckoutWizardPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from "react"; import { Head, usePage } from "@inertiajs/react"; import MarketingLayout from "@/layouts/mainWebsite"; -import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types"; +import type { CheckoutPackage, OAuthProfilePrefill } from "./checkout/types"; import { CheckoutWizard } from "./checkout/CheckoutWizard"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import toast from "react-hot-toast"; @@ -14,7 +14,12 @@ interface CheckoutWizardPageProps { googleAuth?: { status?: string | null; error?: string | null; - profile?: GoogleProfilePrefill | null; + profile?: OAuthProfilePrefill | null; + }; + facebookAuth?: { + status?: string | null; + error?: string | null; + profile?: OAuthProfilePrefill | null; }; paddle?: { environment?: string | null; @@ -27,11 +32,13 @@ const CheckoutWizardPage: React.FC = ({ packageOptions, privacyHtml, googleAuth, + facebookAuth, paddle, }) => { const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null }, flash?: { verification?: { status: string; title?: string; message?: string } } }>(); const currentUser = page.props.auth?.user ?? null; const googleProfile = googleAuth?.profile ?? null; + const facebookProfile = facebookAuth?.profile ?? null; const { t: tAuth } = useTranslation('auth'); const verificationFlash = page.props.flash?.verification; @@ -85,6 +92,7 @@ const CheckoutWizardPage: React.FC = ({ privacyHtml={privacyHtml} initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null} googleProfile={googleProfile} + facebookProfile={facebookProfile} paddle={paddle ?? null} />
diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index 8a14d8f..e9eca9d 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -4,7 +4,7 @@ import { Steps } from "@/components/ui/Steps"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext"; -import type { CheckoutPackage, CheckoutStepId, GoogleProfilePrefill } from "./types"; +import type { CheckoutPackage, CheckoutStepId, OAuthProfilePrefill } from "./types"; import { PackageStep } from "./steps/PackageStep"; import { AuthStep } from "./steps/AuthStep"; import { ConfirmationStep } from "./steps/ConfirmationStep"; @@ -25,7 +25,8 @@ interface CheckoutWizardProps { pending_purchase?: boolean; } | null; initialStep?: CheckoutStepId; - googleProfile?: GoogleProfilePrefill | null; + googleProfile?: OAuthProfilePrefill | null; + facebookProfile?: OAuthProfilePrefill | null; paddle?: { environment?: string | null; client_token?: string | null; @@ -68,9 +69,9 @@ const PaymentStepFallback: React.FC = () => ( const WizardBody: React.FC<{ privacyHtml: string; - googleProfile?: GoogleProfilePrefill | null; - onClearGoogleProfile?: () => void; -}> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => { + prefillProfile?: OAuthProfilePrefill | null; + onClearPrefill?: () => void; +}> = ({ privacyHtml, prefillProfile, onClearPrefill }) => { const primaryCtaClassName = "min-w-[160px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground"; const { t } = useTranslation('marketing'); const { @@ -246,8 +247,8 @@ const WizardBody: React.FC<{ {currentStep === "auth" && ( )} {currentStep === "payment" && ( @@ -288,9 +289,10 @@ export const CheckoutWizard: React.FC = ({ initialAuthUser, initialStep, googleProfile, + facebookProfile, paddle, }) => { - const [storedProfile, setStoredProfile] = useState(() => { + const [storedGoogleProfile, setStoredGoogleProfile] = useState(() => { if (typeof window === 'undefined') { return null; } @@ -301,7 +303,7 @@ export const CheckoutWizard: React.FC = ({ } try { - return JSON.parse(raw) as GoogleProfilePrefill; + return JSON.parse(raw) as OAuthProfilePrefill; } catch (error) { console.warn('Failed to parse checkout google profile from storage', error); window.localStorage.removeItem('checkout-google-profile'); @@ -314,21 +316,64 @@ export const CheckoutWizard: React.FC = ({ return; } - setStoredProfile(googleProfile); + setStoredGoogleProfile(googleProfile); if (typeof window !== 'undefined') { window.localStorage.setItem('checkout-google-profile', JSON.stringify(googleProfile)); } }, [googleProfile]); - const clearStoredProfile = useCallback(() => { - setStoredProfile(null); + const clearStoredGoogleProfile = useCallback(() => { + setStoredGoogleProfile(null); if (typeof window !== 'undefined') { window.localStorage.removeItem('checkout-google-profile'); } }, []); - const effectiveProfile = googleProfile ?? storedProfile; + const [storedFacebookProfile, setStoredFacebookProfile] = useState(() => { + if (typeof window === 'undefined') { + return null; + } + + const raw = window.localStorage.getItem('checkout-facebook-profile'); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as OAuthProfilePrefill; + } catch (error) { + console.warn('Failed to parse checkout facebook profile from storage', error); + window.localStorage.removeItem('checkout-facebook-profile'); + return null; + } + }); + + useEffect(() => { + if (!facebookProfile) { + return; + } + + setStoredFacebookProfile(facebookProfile); + + if (typeof window !== 'undefined') { + window.localStorage.setItem('checkout-facebook-profile', JSON.stringify(facebookProfile)); + } + }, [facebookProfile]); + + const clearStoredFacebookProfile = useCallback(() => { + setStoredFacebookProfile(null); + if (typeof window !== 'undefined') { + window.localStorage.removeItem('checkout-facebook-profile'); + } + }, []); + + const clearStoredProfiles = useCallback(() => { + clearStoredGoogleProfile(); + clearStoredFacebookProfile(); + }, [clearStoredFacebookProfile, clearStoredGoogleProfile]); + + const effectiveProfile = facebookProfile ?? storedFacebookProfile ?? googleProfile ?? storedGoogleProfile; return ( = ({ > ); diff --git a/resources/js/pages/marketing/checkout/__tests__/AuthStep.google.test.tsx b/resources/js/pages/marketing/checkout/__tests__/AuthStep.google.test.tsx index c7299af..ea05b5d 100644 --- a/resources/js/pages/marketing/checkout/__tests__/AuthStep.google.test.tsx +++ b/resources/js/pages/marketing/checkout/__tests__/AuthStep.google.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; vi.mock('@inertiajs/react', () => ({ - usePage: () => ({ props: { locale: 'de', googleAuth: {} } }), + usePage: () => ({ props: { locale: 'de', googleAuth: {}, facebookAuth: {} } }), })); vi.mock('react-i18next', () => ({ @@ -77,14 +77,16 @@ vi.mock('lucide-react', () => ({ import { AuthStep } from '../steps/AuthStep'; -describe('AuthStep Google controls', () => { - it('hides the top Google button when switching to login mode', () => { +describe('AuthStep OAuth controls', () => { + it('hides the OAuth buttons when switching to login mode', () => { render(); expect(screen.getByText('checkout.auth_step.continue_with_google')).toBeInTheDocument(); + expect(screen.getByText('checkout.auth_step.continue_with_facebook')).toBeInTheDocument(); fireEvent.click(screen.getByText('checkout.auth_step.switch_to_login')); expect(screen.queryByText('checkout.auth_step.continue_with_google')).not.toBeInTheDocument(); + expect(screen.queryByText('checkout.auth_step.continue_with_facebook')).not.toBeInTheDocument(); }); }); diff --git a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx index ef6fe86..6a99447 100644 --- a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx @@ -3,7 +3,7 @@ import { usePage } from "@inertiajs/react"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { useCheckoutWizard } from "../WizardContext"; -import type { GoogleProfilePrefill } from '../types'; +import type { OAuthProfilePrefill } from '../types'; import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm"; import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm"; import { Trans, useTranslation } from 'react-i18next'; @@ -14,11 +14,11 @@ import { cn } from "@/lib/utils"; interface AuthStepProps { privacyHtml: string; - googleProfile?: GoogleProfilePrefill; - onClearGoogleProfile?: () => void; + prefill?: OAuthProfilePrefill; + onClearPrefill?: () => void; } -type GoogleAuthFlash = { +type OAuthAuthFlash = { status?: string | null; error?: string | null; }; @@ -34,26 +34,55 @@ const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => ( ); -export const AuthStep: React.FC = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => { +const FacebookIcon: React.FC<{ className?: string }> = ({ className }) => ( + +); + +export const AuthStep: React.FC = ({ privacyHtml, prefill, onClearPrefill }) => { const { t } = useTranslation('marketing'); const page = usePage<{ locale?: string }>(); const locale = page.props.locale ?? "de"; - const googleAuth = useMemo(() => { - const props = page.props as { googleAuth?: GoogleAuthFlash }; + const googleAuth = useMemo(() => { + const props = page.props as { googleAuth?: OAuthAuthFlash }; return props.googleAuth ?? {}; }, [page.props]); + const facebookAuth = useMemo(() => { + const props = page.props as { facebookAuth?: OAuthAuthFlash }; + return props.facebookAuth ?? {}; + }, [page.props]); const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard(); const [mode, setMode] = useState<'login' | 'register'>('register'); const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); + const [isRedirectingToFacebook, setIsRedirectingToFacebook] = useState(false); const [showGoogleHelper, setShowGoogleHelper] = useState(false); - const showGoogleControls = mode === 'register'; + const showOauthControls = mode === 'register'; + const authErrorTitle = facebookAuth?.error + ? t('checkout.auth_step.facebook_error_title') + : t('checkout.auth_step.google_error_title'); useEffect(() => { if (googleAuth?.status === 'signin') { toast.success(t('checkout.auth_step.google_success_toast')); - onClearGoogleProfile?.(); + onClearPrefill?.(); } - }, [googleAuth?.status, onClearGoogleProfile, t]); + }, [googleAuth?.status, onClearPrefill, t]); + + useEffect(() => { + if (facebookAuth?.status === 'signin') { + toast.success(t('checkout.auth_step.facebook_success_toast')); + onClearPrefill?.(); + } + }, [facebookAuth?.status, onClearPrefill, t]); useEffect(() => { if (googleAuth?.error) { @@ -61,6 +90,12 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, } }, [googleAuth?.error]); + useEffect(() => { + if (facebookAuth?.error) { + toast.error(facebookAuth.error); + } + }, [facebookAuth?.error]); + useEffect(() => { if (mode !== 'login') { return; @@ -82,7 +117,7 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, name: payload.name ?? undefined, pending_purchase: Boolean(payload.pending_purchase), }); - onClearGoogleProfile?.(); + onClearPrefill?.(); nextStep(); }; @@ -97,7 +132,7 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, }); } - onClearGoogleProfile?.(); + onClearPrefill?.(); nextStep(); }; @@ -115,6 +150,20 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, window.location.href = `/checkout/auth/google?${params.toString()}`; }, [locale, selectedPackage, t]); + const handleFacebookLogin = useCallback(() => { + if (!selectedPackage) { + toast.error(t('checkout.auth_step.facebook_missing_package')); + return; + } + + setIsRedirectingToFacebook(true); + const params = new URLSearchParams({ + package_id: String(selectedPackage.id), + locale, + }); + window.location.href = `/checkout/auth/facebook?${params.toString()}`; + }, [locale, selectedPackage, t]); + if (isAuthenticated && authUser) { return (
@@ -159,37 +208,52 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, > {t('checkout.auth_step.switch_to_login')} - {showGoogleControls && ( -
+ {showOauthControls && ( +
+
+ + + + +
- - -
)}
- {showGoogleControls && ( + {showOauthControls && ( {t('checkout.auth_step.google_helper')} @@ -198,10 +262,10 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, )} - {googleAuth?.error && ( + {(googleAuth?.error || facebookAuth?.error) && ( - {t('checkout.auth_step.google_error_title')} - {googleAuth.error} + {authErrorTitle} + {facebookAuth?.error ?? googleAuth?.error} )} @@ -213,8 +277,8 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, privacyHtml={privacyHtml} locale={locale} onSuccess={handleRegisterSuccess} - prefill={googleProfile} - onClearGoogleProfile={onClearGoogleProfile} + prefill={prefill} + onClearPrefill={onClearPrefill} /> ) ) : ( diff --git a/resources/js/pages/marketing/checkout/types.ts b/resources/js/pages/marketing/checkout/types.ts index 7927734..a523945 100644 --- a/resources/js/pages/marketing/checkout/types.ts +++ b/resources/js/pages/marketing/checkout/types.ts @@ -1,6 +1,6 @@ export type CheckoutStepId = 'package' | 'auth' | 'payment' | 'confirmation'; -export interface GoogleProfilePrefill { +export interface OAuthProfilePrefill { email?: string; name?: string; given_name?: string; diff --git a/resources/lang/de/auth.json b/resources/lang/de/auth.json index 67e6fa4..c28065e 100644 --- a/resources/lang/de/auth.json +++ b/resources/lang/de/auth.json @@ -61,6 +61,8 @@ "oauth_divider": "oder", "google_cta": "Mit Google anmelden", "google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.", + "facebook_cta": "Mit Facebook anmelden", + "facebook_helper": "Nutze dein Facebook-Konto, um dich sicher bei der Eventverwaltung anzumelden.", "no_account": "Noch keinen Zugang?", "sign_up": "Jetzt registrieren" }, diff --git a/resources/lang/en/auth.json b/resources/lang/en/auth.json index 8528490..3eab867 100644 --- a/resources/lang/en/auth.json +++ b/resources/lang/en/auth.json @@ -61,6 +61,8 @@ "oauth_divider": "or", "google_cta": "Continue with Google", "google_helper": "Use your Google account to access the event dashboard securely.", + "facebook_cta": "Continue with Facebook", + "facebook_helper": "Use your Facebook account to access the event dashboard securely.", "no_account": "Don't have access yet?", "sign_up": "Create an account" }, diff --git a/routes/web.php b/routes/web.php index 99e5729..f7a7a23 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\Auth\RegisteredUserController; use App\Http\Controllers\CheckoutController; +use App\Http\Controllers\CheckoutFacebookController; use App\Http\Controllers\CheckoutGoogleController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\LegalPageController; @@ -17,6 +18,7 @@ use App\Http\Controllers\ProfileDataExportController; use App\Http\Controllers\SuperAdmin\DataExportController as SuperAdminDataExportController; use App\Http\Controllers\Tenant\EventPhotoArchiveController; use App\Http\Controllers\TenantAdminAuthController; +use App\Http\Controllers\TenantAdminFacebookController; use App\Http\Controllers\TenantAdminGoogleController; use App\Http\Controllers\WithdrawalController; use App\Models\Package; @@ -331,6 +333,10 @@ Route::prefix('event-admin')->group(function () { ->name('tenant.admin.google.redirect'); Route::get('/auth/google/callback', [TenantAdminGoogleController::class, 'callback']) ->name('tenant.admin.google.callback'); + Route::get('/auth/facebook', [TenantAdminFacebookController::class, 'redirect']) + ->name('tenant.admin.facebook.redirect'); + Route::get('/auth/facebook/callback', [TenantAdminFacebookController::class, 'callback']) + ->name('tenant.admin.facebook.callback'); // Protected routes (auth check inside controller) Route::get('/logout', $authAdmin)->name('tenant.admin.logout'); @@ -383,6 +389,8 @@ Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('chec Route::post('/checkout/register', [CheckoutController::class, 'register'])->name('checkout.register'); Route::get('/checkout/auth/google', [CheckoutGoogleController::class, 'redirect'])->name('checkout.google.redirect'); Route::get('/checkout/auth/google/callback', [CheckoutGoogleController::class, 'callback'])->name('checkout.google.callback'); +Route::get('/checkout/auth/facebook', [CheckoutFacebookController::class, 'redirect'])->name('checkout.facebook.redirect'); +Route::get('/checkout/auth/facebook/callback', [CheckoutFacebookController::class, 'callback'])->name('checkout.facebook.callback'); Route::post('/checkout/track-abandoned', [CheckoutController::class, 'trackAbandonedCheckout'])->name('checkout.track-abandoned'); Route::post('/set-locale', [LocaleController::class, 'set'])->name('set-locale'); diff --git a/tests/Feature/Auth/TenantAdminFacebookControllerTest.php b/tests/Feature/Auth/TenantAdminFacebookControllerTest.php new file mode 100644 index 0000000..d7deb5b --- /dev/null +++ b/tests/Feature/Auth/TenantAdminFacebookControllerTest.php @@ -0,0 +1,130 @@ +once()->with('facebook')->andReturn($driver); + $driver->shouldReceive('scopes')->once()->with(['email'])->andReturnSelf(); + $driver->shouldReceive('fields')->once()->with(['name', 'email', 'first_name', 'last_name'])->andReturnSelf(); + $driver->shouldReceive('redirect')->once()->andReturn(new RedirectResponse('https://facebook.com/auth')); + + $encodedReturn = rtrim(strtr(base64_encode(url('/test')), '+/', '-_'), '='); + + $response = $this->get('/event-admin/auth/facebook?return_to='.$encodedReturn); + + $response->assertRedirect('https://facebook.com/auth'); + $this->assertSame($encodedReturn, session('tenant_oauth_return_to')); + } + + public function test_callback_logs_in_tenant_admin_and_redirects_to_encoded_target(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + ]); + + $socialiteUser = tap(new SocialiteUser)->map([ + 'id' => 'facebook-id-123', + 'name' => 'Facebook Tenant Admin', + 'email' => $user->email, + ]); + + $driver = Mockery::mock(); + Socialite::shouldReceive('driver')->once()->with('facebook')->andReturn($driver); + $driver->shouldReceive('user')->once()->andReturn($socialiteUser); + + $targetUrl = url('/event-admin/dashboard?foo=bar'); + $encodedReturn = rtrim(strtr(base64_encode($targetUrl), '+/', '-_'), '='); + + $this->withSession([ + 'tenant_oauth_return_to' => $encodedReturn, + ]); + + $response = $this->get('/event-admin/auth/facebook/callback'); + + $response->assertRedirect($targetUrl); + $this->assertAuthenticatedAs($user); + } + + public function test_callback_ignores_intended_and_uses_admin_fallback(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + ]); + + $socialiteUser = tap(new SocialiteUser)->map([ + 'id' => 'facebook-id-456', + 'name' => 'Facebook Tenant Admin', + 'email' => $user->email, + ]); + + $driver = Mockery::mock(); + Socialite::shouldReceive('driver')->once()->with('facebook')->andReturn($driver); + $driver->shouldReceive('user')->once()->andReturn($socialiteUser); + + $this->withSession([ + 'url.intended' => '/packages', + ]); + + $response = $this->get('/event-admin/auth/facebook/callback'); + + $response->assertRedirect('/event-admin/dashboard'); + $this->assertAuthenticatedAs($user); + } + + public function test_callback_redirects_back_when_user_not_found(): void + { + $socialiteUser = tap(new SocialiteUser)->map([ + 'id' => 'missing-user', + 'name' => 'Unknown User', + 'email' => 'unknown@example.com', + ]); + + $driver = Mockery::mock(); + Socialite::shouldReceive('driver')->once()->with('facebook')->andReturn($driver); + $driver->shouldReceive('user')->once()->andReturn($socialiteUser); + + $response = $this->get('/event-admin/auth/facebook/callback'); + + $response->assertRedirect(); + $this->assertStringContainsString('error=facebook_no_match', $response->headers->get('Location')); + $this->assertFalse(Auth::check()); + } + + public function test_callback_handles_socialite_failure(): void + { + $driver = Mockery::mock(); + Socialite::shouldReceive('driver')->once()->with('facebook')->andReturn($driver); + $driver->shouldReceive('user')->once()->andThrow(new \RuntimeException('boom')); + + $response = $this->get('/event-admin/auth/facebook/callback'); + + $response->assertRedirect(); + $this->assertStringContainsString('error=facebook_failed', $response->headers->get('Location')); + } +} diff --git a/tests/Feature/CheckoutFacebookControllerTest.php b/tests/Feature/CheckoutFacebookControllerTest.php new file mode 100644 index 0000000..5fd0811 --- /dev/null +++ b/tests/Feature/CheckoutFacebookControllerTest.php @@ -0,0 +1,112 @@ +create(); + + $provider = Mockery::mock(SocialiteProvider::class); + $provider->shouldReceive('scopes')->andReturnSelf(); + $provider->shouldReceive('fields')->andReturnSelf(); + $provider->shouldReceive('redirect')->once()->andReturn(redirect('/facebook/auth')); + + $this->mock(SocialiteFactory::class, function ($mock) use ($provider) { + $mock->shouldReceive('driver')->with('facebook')->andReturn($provider); + }); + + $response = $this->get('/checkout/auth/facebook?package_id='.$package->id.'&locale=de'); + + $response->assertRedirect('/facebook/auth'); + $this->assertSame($package->id, session('checkout_facebook_payload.package_id')); + } + + public function test_callback_logs_in_existing_user_and_attaches_tenant(): void + { + $package = Package::factory()->create(['price' => 0]); + $existingUser = User::factory()->create([ + 'email' => 'checkout-facebook@example.com', + 'pending_purchase' => false, + ]); + + $facebookUser = Mockery::mock(SocialiteUserContract::class); + $facebookUser->shouldReceive('getEmail')->andReturn('checkout-facebook@example.com'); + $facebookUser->shouldReceive('getName')->andReturn('Checkout Facebook'); + $facebookUser->shouldReceive('getAvatar')->andReturn(null); + $facebookUser->shouldReceive('getRaw')->andReturn([]); + + $provider = Mockery::mock(SocialiteProvider::class); + $provider->shouldReceive('user')->andReturn($facebookUser); + + $this->mock(SocialiteFactory::class, function ($mock) use ($provider) { + $mock->shouldReceive('driver')->with('facebook')->andReturn($provider); + }); + + $response = $this + ->withSession([ + 'checkout_facebook_payload' => ['package_id' => $package->id, 'locale' => 'de'], + ]) + ->get('/checkout/auth/facebook/callback'); + + $response->assertRedirect(CheckoutRoutes::wizardUrl($package->id, 'de')); + + $this->assertAuthenticatedAs($existingUser); + + $user = auth()->user(); + $this->assertSame('checkout-facebook@example.com', $user->email); + $this->assertTrue($user->pending_purchase); + $this->assertNotNull($user->tenant); + $this->assertDatabaseHas('tenant_packages', [ + 'tenant_id' => $user->tenant_id, + 'package_id' => $package->id, + ]); + } + + public function test_callback_with_missing_email_flashes_error(): void + { + $package = Package::factory()->create(); + + $facebookUser = Mockery::mock(SocialiteUserContract::class); + $facebookUser->shouldReceive('getEmail')->andReturn(null); + $facebookUser->shouldReceive('getName')->andReturn('No Email'); + $facebookUser->shouldReceive('getAvatar')->andReturn(null); + $facebookUser->shouldReceive('getRaw')->andReturn([]); + + $provider = Mockery::mock(SocialiteProvider::class); + $provider->shouldReceive('user')->andReturn($facebookUser); + + $this->mock(SocialiteFactory::class, function ($mock) use ($provider) { + $mock->shouldReceive('driver')->with('facebook')->andReturn($provider); + }); + + $response = $this + ->withSession([ + 'checkout_facebook_payload' => ['package_id' => $package->id, 'locale' => 'en'], + ]) + ->get('/checkout/auth/facebook/callback'); + + $response->assertRedirect(CheckoutRoutes::wizardUrl($package->id, 'en')); + $response->assertSessionHas('checkout_facebook_error'); + $this->assertGuest(); + } +}