diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index b471f67..ccf0c74 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -132,13 +132,17 @@ class MarketingController extends Controller $stripePublishableKey = config('services.stripe.key'); $privacyHtml = view('legal.datenschutz-partial', ['locale' => app()->getLocale()])->render(); - return Inertia::render('marketing/PurchaseWizard', [ + $csp = "default-src 'self'; script-src 'self' 'unsafe-inline' http://localhost:5173 https://js.stripe.com https://js.stripe.network; style-src 'self' 'unsafe-inline' data: https:; img-src 'self' data: https: blob:; font-src 'self' data: https:; connect-src 'self' http://localhost:5173 ws://localhost:5173 https://api.stripe.com https://api.stripe.network wss://*.stripe.network; media-src data: blob: 'self' https: https://js.stripe.com https://*.stripe.com; frame-src 'self' https://js.stripe.com https://*.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self';"; + + $response = Inertia::render('marketing/PurchaseWizard', [ 'package' => $package, 'stripePublishableKey' => $stripePublishableKey, - 'paypalClientId' => config('services.paypal.client_id'), 'privacyHtml' => $privacyHtml, - ]); + ])->toResponse($request); + $response->headers->set('Content-Security-Policy', $csp); + return $response; } + /** * Checkout for Stripe with auth metadata. */ diff --git a/app/Http/Controllers/PurchaseWizardController.php b/app/Http/Controllers/PurchaseWizardController.php deleted file mode 100644 index a5e0e38..0000000 --- a/app/Http/Controllers/PurchaseWizardController.php +++ /dev/null @@ -1,465 +0,0 @@ -validate([ - 'login' => ['required', 'string'], - 'password' => ['required', 'string'], - 'remember' => ['nullable', 'boolean'], - ]); - - $credentials = ['password' => $data['password']]; - - if (filter_var($data['login'], FILTER_VALIDATE_EMAIL)) { - $credentials['email'] = $data['login']; - } else { - $credentials['username'] = $data['login']; - } - - if (! Auth::attempt($credentials, (bool) ($data['remember'] ?? false))) { - throw ValidationException::withMessages([ - 'login' => __('auth.failed'), - ]); - } - - $request->session()->regenerate(); - - $user = $request->user(); - - return response()->json([ - 'status' => 'authenticated', - 'user' => $this->transformUser($user), - 'next_step' => 'payment', - 'needs_verification' => $user?->email_verified_at === null, - ]); -} - -public function register(Request $request): JsonResponse -{ - $data = $request->validate([ - 'username' => ['required', 'string', 'max:255', 'unique:users,username'], - 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'], - 'password' => ['required', 'confirmed', \Illuminate\Validation\Rules\Password::defaults()], - 'first_name' => ['required', 'string', 'max:255'], - 'last_name' => ['required', 'string', 'max:255'], - 'address' => ['required', 'string', 'max:500'], - 'phone' => ['required', 'string', 'max:20'], - 'privacy_consent' => ['accepted'], - 'package_id' => ['nullable', 'exists:packages,id'], - ]); - - $shouldAutoVerify = app()->environment(['local', 'testing']); - $package = $data['package_id'] ? Package::find($data['package_id']) : null; - - DB::beginTransaction(); - - try { - $user = User::create([ - 'username' => $data['username'], - 'email' => $data['email'], - 'first_name' => $data['first_name'], - 'last_name' => $data['last_name'], - 'address' => $data['address'], - 'phone' => $data['phone'], - 'password' => Hash::make($data['password']), - 'role' => 'user', - 'pending_purchase' => $package && (($package->price ?? 0) > 0), - ]); - - $tenant = Tenant::create([ - 'user_id' => $user->id, - 'name' => trim($data['first_name'].' '.$data['last_name']), - 'slug' => Str::slug($data['first_name'].' '.$data['last_name'].'-'.now()->timestamp), - 'email' => $data['email'], - 'is_active' => true, - 'is_suspended' => false, - 'event_credits_balance' => 0, - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, - 'settings' => json_encode([ - 'branding' => [ - 'logo_url' => null, - 'primary_color' => '#3B82F6', - 'secondary_color' => '#1F2937', - 'font_family' => 'Inter, sans-serif', - ], - 'features' => [ - 'photo_likes_enabled' => false, - 'event_checklist' => false, - 'custom_domain' => false, - 'advanced_analytics' => false, - ], - 'custom_domain' => null, - 'contact_email' => $data['email'], - 'event_default_type' => 'general', - ]), - ]); - - if ($shouldAutoVerify) { - $user->forceFill(['email_verified_at' => now()])->save(); - } - - $assignedPackage = null; - - if ($package && (float) $package->price <= 0.0) { - $assignedPackage = $package; - - TenantPackage::updateOrCreate( - [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - ], - [ - 'price' => 0, - 'active' => true, - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), - ] - ); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => 'free', - 'price' => 0, - 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', - 'purchased_at' => now(), - 'refunded' => false, - ]); - - $tenant->update(['subscription_status' => 'active']); - $user->forceFill(['pending_purchase' => false, 'role' => 'tenant_admin'])->save(); - } - - DB::commit(); - } catch (\Throwable $e) { - DB::rollBack(); - throw $e; - } - - event(new Registered($user)); - - Auth::login($user); - $request->session()->regenerate(); - - Mail::to($user)->queue(new \App\Mail\Welcome($user)); - - $nextStep = 'payment'; - - if ($assignedPackage) { - $nextStep = 'success'; - } - - return response()->json([ - 'status' => 'registered', - 'user' => $this->transformUser($user), - 'next_step' => $nextStep, - 'needs_verification' => $user->email_verified_at === null, - 'package' => $package ? [ - 'id' => $package->id, - 'name' => $package->name, - 'price' => $package->price, - 'type' => $package->type, - ] : null, - ]); -} - - -public function createStripeIntent(Request $request): JsonResponse -{ - $data = $request->validate([ - 'package_id' => ['required', 'exists:packages,id'], - ]); - - $user = $request->user(); - if (! $user) { - throw ValidationException::withMessages(['auth' => __('auth.login')]); - } - - $tenant = $user->tenant; - if (! $tenant) { - throw ValidationException::withMessages(['tenant' => 'Tenant not found']); - } - - $package = Package::findOrFail($data['package_id']); - if ($package->price <= 0) { - throw ValidationException::withMessages(['package_id' => 'Stripe payment is not required for this package.']); - } - - Stripe::setApiKey(config('services.stripe.secret')); - - $intent = PaymentIntent::create([ - 'amount' => (int) round($package->price * 100), - 'currency' => 'eur', - 'metadata' => [ - 'user_id' => $user->id, - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'package_type' => $package->type, - ], - 'automatic_payment_methods' => ['enabled' => true], - ]); - - return response()->json([ - 'client_secret' => $intent->client_secret, - 'payment_intent_id' => $intent->id, - ]); -} - -public function completeStripe(Request $request): JsonResponse -{ - $data = $request->validate([ - 'package_id' => ['required', 'exists:packages,id'], - 'payment_intent_id' => ['required', 'string'], - ]); - - $user = $request->user(); - if (! $user) { - throw ValidationException::withMessages(['auth' => __('auth.login')]); - } - - $package = Package::findOrFail($data['package_id']); - $tenant = $this->resolveTenant($user->id); - - Stripe::setApiKey(config('services.stripe.secret')); - $intent = PaymentIntent::retrieve($data['payment_intent_id']); - - if ($intent->status !== 'succeeded') { - throw ValidationException::withMessages(['payment' => 'The payment is not completed.']); - } - - $this->finalizePurchase($tenant, $package, 'stripe', [ - 'payment_intent' => $intent->id, - ]); - - return response()->json(['status' => 'completed']); -} - -public function createPaypalOrder(Request $request): JsonResponse -{ - $data = $request->validate([ - 'package_id' => ['required', 'exists:packages,id'], - ]); - - $user = $request->user(); - if (! $user) { - throw ValidationException::withMessages(['auth' => __('auth.login')]); - } - - $tenant = $this->resolveTenant($user->id); - $package = Package::findOrFail($data['package_id']); - if ($package->price <= 0) { - throw ValidationException::withMessages(['package_id' => 'PayPal payment is not required for this package.']); - } - - $client = $this->makePaypalClient(); - $orders = $client->orders(); - - $createRequest = new OrdersCreateRequest(); - $createRequest->prefer('return=representation'); - $createRequest->body = [ - 'intent' => 'CAPTURE', - 'purchase_units' => [[ - 'amount' => [ - 'currency_code' => 'EUR', - 'value' => number_format($package->price, 2, '.', ''), - ], - 'description' => 'Package: '.$package->name, - 'custom_id' => json_encode([ - 'user_id' => $user->id, - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'package_type' => $package->type, - ]), - ]], - ]; - - try { - $response = $orders->createOrder($createRequest); - $order = $response->result; - - return response()->json([ - 'order_id' => $order->id, - 'status' => $order->status ?? 'CREATED', - ]); - } catch (HttpException $exception) { - Log::error('PayPal order creation failed', [ - 'message' => $exception->getMessage(), - 'status_code' => $exception->statusCode ?? null, - ]); - - return response()->json(['error' => 'Unable to create PayPal order.'], 422); - } -} - -public function capturePaypalOrder(Request $request): JsonResponse -{ - $data = $request->validate([ - 'order_id' => ['required', 'string'], - 'package_id' => ['required', 'exists:packages,id'], - ]); - - $user = $request->user(); - if (! $user) { - throw ValidationException::withMessages(['auth' => __('auth.login')]); - } - - $package = Package::findOrFail($data['package_id']); - $tenant = $this->resolveTenant($user->id); - - $client = $this->makePaypalClient(); - $orders = $client->orders(); - - $captureRequest = new OrdersCaptureRequest($data['order_id']); - $captureRequest->prefer('return=representation'); - - try { - $response = $orders->captureOrder($captureRequest); - $capture = $response->result; - - if (($capture->status ?? null) !== 'COMPLETED') { - return response()->json(['error' => 'Capture incomplete.'], 422); - } - - $customId = $capture->purchaseUnits[0]->customId ?? null; - if ($customId) { - $metadata = json_decode($customId, true); - - if (($metadata['package_id'] ?? null) !== $package->id || ($metadata['tenant_id'] ?? null) !== $tenant->id) { - return response()->json(['error' => 'Order metadata mismatch.'], 422); - } - } - - $this->finalizePurchase($tenant, $package, 'paypal', [ - 'order_id' => $data['order_id'], - 'capture_status' => $capture->status ?? null, - ]); - - return response()->json([ - 'status' => 'captured', - ]); - } catch (HttpException $exception) { - Log::error('PayPal capture failed', [ - 'message' => $exception->getMessage(), - 'status_code' => $exception->statusCode ?? null, - ]); - - return response()->json(['error' => 'Unable to capture PayPal order.'], 422); - } -} - -public function assignFreePackage(Request $request): JsonResponse -{ - $data = $request->validate([ - 'package_id' => ['required', 'exists:packages,id'], - ]); - - $user = $request->user(); - if (! $user) { - throw ValidationException::withMessages(['auth' => __('auth.login')]); - } - - $package = Package::findOrFail($data['package_id']); - if ($package->price > 0) { - throw ValidationException::withMessages(['package_id' => 'Package is not free.']); - } - - $tenant = $this->resolveTenant($user->id); - $this->finalizePurchase($tenant, $package, 'free_wizard'); - - return response()->json(['status' => 'assigned']); -} - -private function resolveTenant(int $userId): Tenant -{ - $tenant = Tenant::where('user_id', $userId)->first(); - - if (! $tenant) { - throw ValidationException::withMessages(['tenant' => 'Tenant not found']); - } - - return $tenant; -} - -private function finalizePurchase(Tenant $tenant, Package $package, string $providerId, array $metadata = []): void -{ - TenantPackage::updateOrCreate( - [ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - ], - [ - 'price' => $package->price, - 'active' => true, - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), - ] - ); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $package->id, - 'provider_id' => $providerId, - 'price' => $package->price, - 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', - 'purchased_at' => now(), - 'metadata' => $metadata ? json_encode($metadata) : null, - 'refunded' => false, - ]); -} - -private function makePaypalClient(): Client -{ - return Client::create([ - 'clientId' => config('services.paypal.client_id'), - 'clientSecret' => config('services.paypal.secret'), - 'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live', - ]); -} - -private function transformUser(?User $user): array -{ - if (! $user) { - return []; - } - - return [ - 'id' => $user->id, - 'email' => $user->email, - 'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: $user->username, - 'pending_purchase' => (bool) $user->pending_purchase, - 'email_verified' => (bool) $user->email_verified_at, - ]; -} -} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index ae11b25..afee9b9 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -66,6 +66,5 @@ class Kernel extends HttpKernel 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'locale' => \App\Http\Middleware\SetLocale::class, - 'stripe.csp' => \App\Http\Middleware\StripeCSP::class, ]; -} +} \ No newline at end of file diff --git a/app/Http/Middleware/StripeCSP.php b/app/Http/Middleware/StripeCSP.php index adb6130..1b23cb1 100644 --- a/app/Http/Middleware/StripeCSP.php +++ b/app/Http/Middleware/StripeCSP.php @@ -9,152 +9,19 @@ use Symfony\Component\HttpFoundation\Response; class StripeCSP { /** - * Apply a CSP that allows Stripe and PayPal assets on the purchase wizard. + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next */ public function handle(Request $request, Closure $next): Response { $response = $next($request); - $isLocal = app()->environment('local'); + $csp = "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.stripe.com https://js.stripe.network; style-src 'self' 'unsafe-inline' data: https:; img-src 'self' data: https: blob:; font-src 'self' data: https:; connect-src 'self' https://api.stripe.com https://api.stripe.network wss://*.stripe.network; media-src 'self' data: blob:; frame-src 'self' https://js.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self';"; - $scriptSrc = [ - "'self'", - "'unsafe-inline'", - 'https://js.stripe.com', - 'https://js.stripe.network', - 'https://m.stripe.network', - 'https://*.stripe.com', - 'https://*.stripe.network', - 'https://www.paypal.com', - 'https://*.paypal.com', - 'https://www.paypalobjects.com', - 'https://*.paypalobjects.com', - ]; - - $styleSrc = [ - "'self'", - "'unsafe-inline'", - 'data:', - 'https:', - 'https://*.stripe.com', - 'https://*.stripe.network', - 'https://www.paypal.com', - 'https://*.paypal.com', - 'https://www.paypalobjects.com', - 'https://*.paypalobjects.com', - ]; - - $imgSrc = [ - "'self'", - 'data:', - 'https:', - 'blob:', - 'https://*.stripe.com', - 'https://*.stripe.network', - 'https://q.stripe.com', - 'https://r.stripe.com', - 'https://www.paypal.com', - 'https://*.paypal.com', - 'https://www.paypalobjects.com', - 'https://*.paypalobjects.com', - ]; - - $fontSrc = [ - "'self'", - 'data:', - 'https:', - 'https://*.stripe.com', - 'https://*.stripe.network', - 'https://www.paypalobjects.com', - 'https://*.paypalobjects.com', - ]; - - $connectSrc = [ - "'self'", - 'https://api.stripe.com', - 'https://api.stripe.network', - 'https://js.stripe.com', - 'https://m.stripe.com', - 'https://m.stripe.network', - 'https://connect.stripe.com', - 'https://*.stripe.com', - 'https://*.stripe.network', - 'https://r.stripe.com', - 'https://q.stripe.com', - 'https://www.paypal.com', - 'https://*.paypal.com', - 'https://www.paypalobjects.com', - 'https://*.paypalobjects.com', - 'wss://*.stripe.network', - ]; - - $mediaSrc = [ - "'self'", - 'data:', - 'blob:', - 'https:', - 'https://js.stripe.com', - 'https://*.stripe.com', - 'https://*.stripe.network', - 'https://m.stripe.network', - 'https://www.paypal.com', - 'https://*.paypal.com', - 'https://www.paypalobjects.com', - 'https://*.paypalobjects.com', - ]; - - $frameSrc = [ - "'self'", - 'https://js.stripe.com', - 'https://*.stripe.com', - 'https://hooks.stripe.com', - 'https://www.paypal.com', - 'https://*.paypal.com', - ]; - - $workerSrc = [ - "'self'", - 'blob:', - 'https://js.stripe.com', - 'https://*.stripe.com', - 'https://*.stripe.network', - 'https://m.stripe.network', - 'https://www.paypal.com', - 'https://*.paypal.com', - ]; - - if ($isLocal) { - $devHost = 'http://localhost:5173'; - - $scriptSrc[] = $devHost; - $styleSrc[] = $devHost; - $imgSrc[] = $devHost; - $fontSrc[] = $devHost; - $connectSrc[] = $devHost; - $connectSrc[] = 'ws://localhost:5173'; - $mediaSrc[] = $devHost; - $frameSrc[] = $devHost; - $workerSrc[] = $devHost; - } - - $directives = [ - "default-src 'self'", - 'script-src ' . implode(' ', $scriptSrc), - 'style-src ' . implode(' ', $styleSrc), - 'img-src ' . implode(' ', $imgSrc), - 'font-src ' . implode(' ', $fontSrc), - 'connect-src ' . implode(' ', $connectSrc), - 'media-src ' . implode(' ', $mediaSrc), - 'frame-src ' . implode(' ', $frameSrc), - 'worker-src ' . implode(' ', $workerSrc), - 'child-src ' . implode(' ', $frameSrc), - "object-src 'none'", - "base-uri 'self'", - "form-action 'self'", - ]; - - $response->headers->set('Content-Security-Policy', implode('; ', $directives) . ';'); + $response->headers->set('Content-Security-Policy', $csp); return $response; } -} +} \ No newline at end of file diff --git a/docs/prp/03-api.md b/docs/prp/03-api.md index a6c83a3..cb7bd63 100644 --- a/docs/prp/03-api.md +++ b/docs/prp/03-api.md @@ -28,58 +28,3 @@ Guest Polling (no WebSockets in v1) Webhooks - Payment provider events, media pipeline status, and deletion callbacks. All signed with shared secret per provider. - -## Purchase Wizard Endpoints (Marketing Flow) - -These endpoints support the frontend purchase wizard for package selection, authentication, and payment. They are web routes under `/purchase/` (not `/api/v1`), designed for Inertia.js integration with JSON responses for AJAX/fetch calls. No tenant middleware for auth steps (pre-tenant creation); auth required for payment. - -### Flow Overview -1. **Package Selection**: User selects package via marketing page; redirects to wizard with package ID. -2. **Auth (Login/Register)**: Handle user creation/login; creates tenant if registering. Returns user data and next_step ('payment' or 'success' for free packages). -3. **Payment**: Create intent/order, complete via provider callback, finalize purchase (assign package, update tenant). -4. **Success**: Redirect to success page; email welcome if new user. - -Error Handling: -- 422 Validation: `{ errors: { field: ['message'] }, message: 'Summary' }` – display in forms without reload. -- 401/403: `{ error: 'Auth required' }` – show login prompt. -- 500/Other: `{ error: 'Server error' }` – generic alert, log trace_id. -- Non-JSON (e.g., 404): Frontend catches "unexpected end of data" and shows "Endpoint not found" or retry. - -All responses: JSON only for AJAX; CSRF-protected. - -### Endpoints - -- **POST /purchase/auth/login** - - Body: `{ login: string (email/username), password: string, remember?: boolean }` - - Response (200): `{ status: 'authenticated', user: { id, email, name, pending_purchase, email_verified }, next_step: 'payment', needs_verification: boolean }` - - Errors: 422 `{ errors: { login: ['Invalid credentials'] } }` - -- **POST /purchase/auth/register** - - Body: `{ username, email, password, password_confirmation, first_name, last_name, address, phone, privacy_consent: boolean, package_id?: number }` - - Response (200): `{ status: 'registered', user: { ... }, next_step: 'payment'|'success', needs_verification: boolean, package?: { id, name, price, type } }` - - Errors: 422 `{ errors: { email: ['Taken'], password: ['Too weak'] } }`; creates tenant/user on success. - -- **POST /purchase/stripe/intent** (auth required) - - Body: `{ package_id: number }` - - Response (200): `{ client_secret: string, payment_intent_id: string }` - - Errors: 422 `{ errors: { package_id: ['Invalid'] } }` - -- **POST /purchase/stripe/complete** (auth required) - - Body: `{ package_id: number, payment_intent_id: string }` - - Response (200): `{ status: 'completed' }` - - Errors: 422 `{ errors: { payment: ['Not succeeded'] } }` – finalizes purchase. - -- **POST /purchase/paypal/order** (auth required) - - Body: `{ package_id: number }` - - Response (200): `{ order_id: string, status: 'CREATED' }` - - Errors: 422 `{ error: 'Order creation failed' }` - -- **POST /purchase/paypal/capture** (auth required) - - Body: `{ order_id: string, package_id: number }` - - Response (200): `{ status: 'captured' }` - - Errors: 422 `{ error: 'Capture incomplete' }` – finalizes purchase. - -- **POST /purchase/free** (auth required) - - Body: `{ package_id: number }` - - Response (200): `{ status: 'assigned' }` - - Errors: 422 `{ errors: { package_id: ['Not free'] } }` – assigns for zero-price packages. diff --git a/resources/js/pages/auth/LoginForm.tsx b/resources/js/pages/auth/LoginForm.tsx index 10b3e32..b928be7 100644 --- a/resources/js/pages/auth/LoginForm.tsx +++ b/resources/js/pages/auth/LoginForm.tsx @@ -1,109 +1,41 @@ - -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useForm } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; -import { LoaderCircle } from 'lucide-react'; +import { LoaderCircle, Mail, Lock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import InputError from '@/components/input-error'; import TextLink from '@/components/text-link'; -import { Alert, AlertDescription } from '@/components/ui/alert'; interface LoginFormProps { - onSuccess?: (payload: any) => void; + onSuccess?: (userData: any) => void; canResetPassword?: boolean; } -const getCsrfToken = () => - (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? ''; - -const parseJson = async (response: Response) => { - if (response.headers.get('Content-Type')?.includes('application/json')) { - const json = await response.json().catch(() => null); - if (json) return json; - } - - const text = await response.text(); - throw new Error(text || 'Invalid server response (unexpected end of data or non-JSON).'); -}; - export default function LoginForm({ onSuccess, canResetPassword = true }: LoginFormProps) { + const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const { t } = useTranslation('auth'); - const csrfToken = useMemo(getCsrfToken, []); - const { data, setData, errors, setError, clearErrors, reset } = useForm({ - login: '', + const { data, setData, post, processing, errors, clearErrors, reset } = useForm({ + email: '', password: '', remember: false, }); - const [hasTriedSubmit, setHasTriedSubmit] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [formError, setFormError] = useState(null); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); + const submit = (e: React.FormEvent) => { + e.preventDefault(); setHasTriedSubmit(true); - setSubmitting(true); - setFormError(null); - clearErrors(); - - try { - const response = await fetch('/purchase/auth/login', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-CSRF-TOKEN': csrfToken, - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ - login: data.login, - password: data.password, - remember: data.remember, - }), - }); - - if (response.ok) { - const payload = await parseJson(response); - reset({ login: payload?.user?.email ?? data.login, password: '', remember: false }); - setHasTriedSubmit(false); + post('/login', { + preserveScroll: true, + onSuccess: () => { if (onSuccess) { - onSuccess(payload); + onSuccess({ user: { email: data.email } }); // Pass basic user info; full user from props in parent } - return; - } - - if (response.status === 422) { - const body = await parseJson(response); - const validationErrors = body.errors ?? {}; - let fallbackMessage: string | null = body.message ?? null; - - Object.entries(validationErrors as Record).forEach(([key, value]) => { - const message = Array.isArray(value) ? value[0] : value; - if (typeof message === 'string') { - setError(key as keyof typeof data, message); - if (!fallbackMessage) { - fallbackMessage = message; - } - } - }); - - if (fallbackMessage) { - setFormError(fallbackMessage); - } - return; - } - - setFormError(t('login.generic_error', { defaultValue: 'Login failed. Please try again.' })); - } catch (error) { - setFormError(t('login.generic_error', { defaultValue: 'Login failed. Please try again.' })); - } finally { - setSubmitting(false); - } + reset(); + }, + }); }; useEffect(() => { @@ -125,27 +57,26 @@ export default function LoginForm({ onSuccess, canResetPassword = true }: LoginF }, [errors, hasTriedSubmit]); return ( -
+
- + { - setData('login', event.target.value); - if (errors.login) { - clearErrors('login'); + value={data.email} + onChange={(e) => { + setData('email', e.target.value); + if (errors.email) { + clearErrors('email'); } }} /> - +
@@ -161,12 +92,11 @@ export default function LoginForm({ onSuccess, canResetPassword = true }: LoginF id="password" type="password" name="password" - autoComplete="current-password" required placeholder={t('login.password_placeholder')} value={data.password} - onChange={(event) => { - setData('password', event.target.value); + onChange={(e) => { + setData('password', e.target.value); if (errors.password) { clearErrors('password'); } @@ -185,19 +115,19 @@ export default function LoginForm({ onSuccess, canResetPassword = true }: LoginF
-
- {(formError || Object.keys(errors).length > 0) && ( - - - {formError || Object.values(errors).join(' ')} - - + {Object.keys(errors).length > 0 && ( +
+

+ {Object.values(errors).join(' ')} +

+
)} - +
); -} +} \ No newline at end of file diff --git a/resources/js/pages/auth/RegisterForm.tsx b/resources/js/pages/auth/RegisterForm.tsx index be72fac..aa5d62b 100644 --- a/resources/js/pages/auth/RegisterForm.tsx +++ b/resources/js/pages/auth/RegisterForm.tsx @@ -1,25 +1,21 @@ - -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useForm } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react'; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; interface RegisterFormProps { packageId?: number; - onSuccess?: (payload: any) => void; + onSuccess?: (userData: any) => void; privacyHtml: string; } -const getCsrfToken = () => - (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? ''; - export default function RegisterForm({ packageId, onSuccess, privacyHtml }: RegisterFormProps) { + const [privacyOpen, setPrivacyOpen] = useState(false); + const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const { t } = useTranslation(['auth', 'common']); - const csrfToken = useMemo(getCsrfToken, []); - const { data, setData, errors, setError, clearErrors, reset } = useForm({ + const { data, setData, post, processing, errors, clearErrors, reset } = useForm({ username: '', email: '', password: '', @@ -29,17 +25,22 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml }: Regi address: '', phone: '', privacy_consent: false, - package_id: packageId ?? null, + package_id: packageId || null, }); - const [privacyOpen, setPrivacyOpen] = useState(false); - const [hasTriedSubmit, setHasTriedSubmit] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [formError, setFormError] = useState(null); - - useEffect(() => { - setData('package_id', packageId ?? null); - }, [packageId]); + const submit = (e: React.FormEvent) => { + e.preventDefault(); + setHasTriedSubmit(true); + post('/register', { + preserveScroll: true, + onSuccess: (page) => { + if (onSuccess) { + onSuccess((page as any).props.auth.user); + } + reset(); + }, + }); + }; useEffect(() => { if (!hasTriedSubmit) { @@ -60,91 +61,8 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml }: Regi } }, [errors, hasTriedSubmit]); - const parseJson = async (response: Response) => { - if (response.headers.get('Content-Type')?.includes('application/json')) { - const json = await response.json().catch(() => null); - if (json) return json; - } - - const text = await response.text(); - throw new Error(text || 'Invalid server response (unexpected end of data or non-JSON).'); - }; - - const submit = async (event: React.FormEvent) => { - event.preventDefault(); - setHasTriedSubmit(true); - setSubmitting(true); - setFormError(null); - clearErrors(); - - try { - const response = await fetch('/purchase/auth/register', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-CSRF-TOKEN': csrfToken, - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ - ...data, - privacy_consent: Boolean(data.privacy_consent), - }), - }); - - if (response.ok) { - const payload = await parseJson(response); - reset({ - username: '', - email: '', - password: '', - password_confirmation: '', - first_name: '', - last_name: '', - address: '', - phone: '', - privacy_consent: false, - package_id: packageId ?? null, - }); - setHasTriedSubmit(false); - if (onSuccess) { - onSuccess(payload); - } - return; - } - - if (response.status === 422) { - const body = await parseJson(response); - const validationErrors = body.errors ?? {}; - let fallbackMessage: string | null = body.message ?? null; - - Object.entries(validationErrors).forEach(([key, value]) => { - const message = Array.isArray(value) ? value[0] : value; - if (typeof message === 'string') { - setError(key, message); - if (!fallbackMessage) { - fallbackMessage = message; - } - } - }); - - if (fallbackMessage) { - setFormError(fallbackMessage); - } - return; - } - - setFormError(t('register.generic_error', { defaultValue: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' })); - } catch (error) { - const message = (error as Error).message || t('register.generic_error', { defaultValue: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }); - setFormError(message); - } finally { - setSubmitting(false); - } - }; - return ( -
+
-
-