feat: Implementierung des Checkout-Logins mit E-Mail/Username-Support

This commit is contained in:
Codex Agent
2025-10-08 21:57:46 +02:00
parent cee279cbab
commit 417b1da484
25 changed files with 730 additions and 212 deletions

View File

@@ -5,10 +5,12 @@ namespace App\Http\Controllers;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
@@ -109,6 +111,55 @@ class CheckoutController extends Controller
]);
}
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'identifier' => 'required|string',
'password' => 'required|string',
'remember' => 'boolean',
'locale' => 'nullable|string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$packageId = $request->session()->get('selected_package_id');
// Custom Auth für Identifier (E-Mail oder Username)
$identifier = $request->identifier;
$user = User::where('email', $identifier)
->orWhere('username', $identifier)
->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json([
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']]
], 422);
}
Auth::login($user, $request->boolean('remember'));
$request->session()->regenerate();
// Checkout-spezifische Logik
DB::transaction(function () use ($request, $user, $packageId) {
if ($packageId && !$user->pending_purchase) {
$user->update(['pending_purchase' => true]);
$request->session()->put('pending_package_id', $packageId);
}
});
return response()->json([
'user' => [
'id' => $user->id,
'email' => $user->email,
'name' => $user->name ?? null,
'pending_purchase' => $user->pending_purchase ?? false,
],
'message' => 'Login erfolgreich',
]);
}
public function createPaymentIntent(Request $request)
{
$request->validate([

View File

@@ -19,9 +19,6 @@ class LocaleController extends Controller
}
// Return JSON response for fetch requests
return response()->json([
'success' => true,
'locale' => App::getLocale(),
]);
return back();
}
}

View File

@@ -2,9 +2,61 @@
## Overview
This document outlines the authentication requirements and implementation details for the Fotospiel tenant backend. The system uses OAuth2 with PKCE (Proof Key for Code Exchange) for secure authorization, providing tenant-specific access tokens for API operations.
This document outlines the authentication requirements and implementation details for the Fotospiel tenant backend. The system uses OAuth2 with PKCE (Proof Key for Code Exchange) for secure authorization, providing tenant-specific access tokens for API operations. Additionally, session-based authentication is used for web interfaces like the checkout wizard, supporting both email and username login.
## Authentication Flow
## Session-Based Authentication (Web/Checkout)
### Checkout Login Flow
- **Endpoint**: `POST /checkout/login`
- **Method**: POST
- **Content-Type**: `application/json`
- **Parameters**:
- `identifier`: Email or username (required, string)
- `password`: User's password (required, string)
- `remember`: Remember me flag (optional, boolean)
- `locale`: Language locale (optional, string, e.g., 'de')
**Authentication Logic**:
- Validate input using Laravel Validator.
- Search for user by email or username using Eloquent query: `User::where('email', $identifier)->orWhere('username', $identifier)->first()`.
- Verify password with `Hash::check()`.
- If valid, log in user with `Auth::login($user, $remember)` and regenerate session.
- Set `pending_purchase = true` if a package is selected (from session) and not already set, wrapped in DB transaction.
- Return JSON response with user data for AJAX handling in frontend.
**Response** (JSON, 200 OK):
```json
{
"user": {
"id": 1,
"email": "user@example.com",
"name": "John Doe",
"pending_purchase": true
},
"message": "Login erfolgreich"
}
```
**Error Response** (JSON, 422 Unprocessable Entity):
```json
{
"errors": {
"identifier": ["Ungültige Anmeldedaten."]
}
}
```
**Security**:
- CSRF protection via `web` middleware.
- Rate limiting recommended (add `throttle:6,1` middleware).
- Password hashing with Laravel's `Hash` facade.
- Session regeneration after login to prevent fixation attacks.
### Integration with Standard Laravel Auth
- Leverages `AuthenticatedSessionController` for core logic where possible, but custom handling for identifier flexibility and checkout context.
- Compatible with Inertia.js for SPA responses.
## OAuth2 Authentication (API)
### 1. Authorization Request
- **Endpoint**: `GET /api/v1/oauth/authorize`
@@ -114,6 +166,7 @@ CREATE TABLE oauth_clients (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT oauth_clients_tenant_id_foreign FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE SET NULL
);
```
```sql
CREATE TABLE oauth_clients (
id VARCHAR(255) PRIMARY KEY,
@@ -159,6 +212,7 @@ CREATE TABLE refresh_tokens (
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
```sql
CREATE TABLE refresh_tokens (
id VARCHAR(255) PRIMARY KEY,
@@ -201,6 +255,7 @@ CREATE TABLE tenant_tokens (
| `/oauth/authorize` | GET | Authorization request | None |
| `/oauth/token` | POST | Token exchange/refresh | None |
| `/api/v1/tenant/me` | GET | Validate token | Bearer Token |
| `/checkout/login` | POST | Session login for checkout (email/username) | None |
### Protected Endpoints
All tenant API endpoints require `Authorization: Bearer {access_token}` header.
@@ -274,6 +329,13 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
"error": "insufficient_scope",
"error_description": "Scope tenant:write required"
}
// 422 Unprocessable Entity (Checkout Login)
{
"errors": {
"identifier": ["Ungültige Anmeldedaten."]
}
}
```
## Implementation Notes
@@ -296,12 +358,14 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- Authorization requests: 10/minute per IP
- Token exchanges: 5/minute per IP
- Token validation: 100/minute per tenant
- Checkout login: 6/minute per IP (add throttle middleware)
### 5. Logging and Monitoring
- Log all authentication attempts (success/failure)
- Monitor token usage patterns
- Alert on unusual activity (multiple failed attempts, token anomalies)
- Track refresh token usage for security analysis
- Log checkout login attempts with identifier type (email/username)
### 6. Database Cleanup
- Cron job to remove expired authorization codes (daily)
@@ -315,18 +379,21 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- State parameter security
- Token signing and verification
- Scope validation middleware
- Checkout login with email and username
### Integration Tests
- Complete OAuth2 flow (authorize → token → validate)
- Token refresh cycle
- Error scenarios (invalid code, expired tokens, state mismatch)
- Concurrent access testing
- Checkout login flow with pending_purchase
### Security Tests
- CSRF protection validation
- PKCE bypass attempts
- Token replay attacks
- Rate limiting enforcement
- Username/email ambiguity handling
## Deployment Considerations
@@ -345,11 +412,6 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- Monitor token expiry patterns
- Alert on PKCE validation failures
- Log all security-related events
- Monitor checkout login success rates and identifier usage
This implementation provides secure, scalable authentication for the Fotospiel tenant system, following OAuth2 best practices with PKCE for public clients.
This implementation provides secure, scalable authentication for the Fotospiel tenant system, following OAuth2 best practices with PKCE for public clients and flexible session auth for web flows.

View File

@@ -20,8 +20,12 @@
"failed_credentials": "Falsche Anmeldedaten.",
"login": {
"title": "Anmelden",
"identifier": "E-Mail oder Username",
"identifier_placeholder": "Geben Sie Ihre E-Mail oder Ihren Username ein",
"username_or_email": "Username oder E-Mail",
"password": "Passwort",
"password_placeholder": "Geben Sie Ihr Passwort ein",
"forgot": "Passwort vergessen?",
"remember": "Angemeldet bleiben",
"submit": "Anmelden"
},
@@ -64,6 +68,68 @@
"privacy_consent": "Datenschutz-Zustimmung"
}
},
"common": {
"ui": {
"language_select": "Sprache auswählen"
}
},
"settings": {
"profile": {
"title": "Profil-Einstellungen",
"section_title": "Profilinformationen",
"description": "Aktualisieren Sie Ihren Namen und Ihre E-Mail-Adresse",
"email": "E-Mail-Adresse",
"email_placeholder": "E-Mail-Adresse",
"username": "Username",
"username_placeholder": "Username",
"language": "Sprache",
"email_unverified": "Ihre E-Mail-Adresse ist nicht verifiziert.",
"resend_verification": "Klicken Sie hier, um die Verifizierungs-E-Mail erneut zu senden.",
"verification_sent": "Ein neuer Verifizierungslink wurde an Ihre E-Mail-Adresse gesendet."
},
"password": {
"title": "Passwort-Einstellungen",
"section_title": "Passwort aktualisieren",
"description": "Stellen Sie sicher, dass Ihr Konto ein langes, zufälliges Passwort verwendet, um sicher zu bleiben",
"current": "Aktuelles Passwort",
"current_placeholder": "Aktuelles Passwort",
"new": "Neues Passwort",
"new_placeholder": "Neues Passwort",
"confirm": "Passwort bestätigen",
"confirm_placeholder": "Passwort bestätigen",
"save_button": "Passwort speichern"
}
},
"reset": {
"password": "Passwort",
"password_placeholder": "Passwort",
"confirm_password": "Passwort bestätigen",
"confirm_password_placeholder": "Passwort bestätigen",
"email": "E-Mail",
"email_placeholder": "email@beispiel.de",
"title": "Passwort zurücksetzen",
"description": "Bitte geben Sie Ihr neues Passwort unten ein",
"submit": "Passwort zurücksetzen"
},
"confirm": {
"password": "Passwort",
"password_placeholder": "Passwort",
"confirm": "Passwort bestätigen",
"title": "Passwort bestätigen",
"description": "Dies ist ein sicherer Bereich der Anwendung. Bitte bestätigen Sie Ihr Passwort, bevor Sie fortfahren.",
"submit": "Passwort bestätigen"
},
"forgot": {
"email": "E-Mail-Adresse",
"email_placeholder": "email@beispiel.de",
"submit": "Passwort-Reset-Link per E-Mail senden",
"back": "Oder zurück zur Anmeldung"
},
"delete_user": {
"password": "Passwort",
"password_placeholder": "Passwort",
"confirm_text": "Sobald Ihr Konto gelöscht ist, werden alle seine Ressourcen und Daten auch dauerhaft gelöscht. Bitte geben Sie Ihr Passwort ein, um zu bestätigen, dass Sie Ihr Konto dauerhaft löschen möchten."
},
"verification": {
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",
"resend": "E-Mail erneut senden"

View File

@@ -31,5 +31,24 @@
"lisa": {
"name": "Lisa K."
}
},
"ui": {
"saved": "Gespeichert",
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg",
"please_enter": "Bitte eingeben",
"click_here": "Hier klicken",
"resend_verification": "Verifizierungs-E-Mail erneut senden",
"verify_email": "E-Mail verifizieren",
"check_email": "Überprüfen Sie Ihre E-Mail auf den Verifizierungslink.",
"login_to_continue": "Melden Sie sich an, um fortzufahren.",
"no_account": "Kein Konto? Registrieren",
"already_registered": "Bereits registriert? Anmelden",
"redirecting": "Weiterleitung zum Admin-Bereich...",
"complete_purchase": "Kauf abschließen",
"email_verify_title": "E-Mail verifizieren",
"email_verify_desc": "Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Verifizierungslink.",
"language_select": "Sprache wählen"
}
}

View File

@@ -1,7 +1,11 @@
{
"header": {
"login:" : "Anmelden",
"register": "Registrieren"
"login": "Anmelden",
"register": "Registrieren",
"home": "Startseite",
"packages": "Pakete",
"blog": "Blog",
"contact": "Kontakt"
},
"home": {
"title": "Startseite - Fotospiel",
@@ -129,16 +133,16 @@
"what_customers_say": "Was unsere Kunden sagen",
"close": "Schließen",
"to_order": "Jetzt bestellen",
"currency": {
"euro": "€"
},
"view_details": "Details ansehen",
"feature": "Feature",
"details": "Details",
"customer_opinions": "Kundenmeinungen",
"errors": {
"select_package": "Bitte wählen Sie ein Paket aus."
}
},
"currency": {
"euro": "€"
},
"view_details": "Details ansehen",
"feature": "Feature"
},
"blog": {
"title": "Fotospiel - Blog",
@@ -337,5 +341,82 @@
"name": "Lisa K."
}
}
},
"checkout": {
"title": "Checkout",
"subtitle": "Sicheren Kaufprozess",
"back": "Zurück",
"next": "Weiter",
"cancel": "Abbrechen",
"package_step": {
"title": "Paket wählen",
"subtitle": "Auswahl und Vergleich",
"description": "Wähle das passende Paket für deine Bedürfnisse",
"no_package_selected": "Kein Paket ausgewählt. Bitte wähle ein Paket aus der Paketübersicht.",
"alternatives_title": "Alternative Pakete",
"no_alternatives": "Keine weiteren Pakete in dieser Kategorie verfügbar.",
"next_to_account": "Weiter zum Konto",
"loading": "Wird geladen..."
},
"auth_step": {
"title": "Konto",
"subtitle": "Anmelden oder Registrieren",
"description": "Erstellen Sie ein Konto oder melden Sie sich an, um mit dem Kauf fortzufahren.",
"already_logged_in_title": "Bereits eingeloggt",
"already_logged_in_desc": "Sie sind bereits als {email} eingeloggt.",
"next_to_payment": "Weiter zur Zahlung",
"switch_to_register": "Registrieren",
"switch_to_login": "Anmelden",
"google_coming_soon": "Google-Login kommt bald im Comfort-Delta."
},
"payment_step": {
"title": "Zahlung",
"subtitle": "Sichere Zahlung",
"description": "Schließen Sie Ihren Kauf sicher mit Ihrer gewählten Zahlungsmethode ab.",
"free_package_title": "Kostenloses Paket",
"free_package_desc": "Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.",
"activate_package": "Paket aktivieren",
"loading_payment": "Zahlungsdaten werden geladen...",
"secure_payment_desc": "Sichere Zahlung mit Kreditkarte, Debitkarte oder SEPA-Lastschrift.",
"payment_failed": "Zahlung fehlgeschlagen. ",
"error_card": "Kartenfehler aufgetreten.",
"error_validation": "Eingabedaten sind ungültig.",
"error_connection": "Verbindungsfehler. Bitte Internetverbindung prüfen.",
"error_server": "Serverfehler. Bitte später erneut versuchen.",
"error_auth": "Authentifizierungsfehler. Bitte Seite neu laden.",
"error_unknown": "Unbekannter Fehler aufgetreten.",
"processing": "Zahlung wird verarbeitet. Bitte warten...",
"needs_method": "Zahlungsmethode wird benötigt. Bitte Kartendaten überprüfen.",
"needs_confirm": "Zahlung muss bestätigt werden.",
"unexpected_status": "Unerwarteter Zahlungsstatus: {status}",
"processing_btn": "Verarbeitung...",
"pay_now": "Jetzt bezahlen (€{price})",
"stripe_not_loaded": "Stripe ist nicht initialisiert. Bitte Seite neu laden.",
"network_error": "Netzwerkfehler beim Laden der Zahlungsdaten",
"payment_intent_error": "Fehler beim Laden der Zahlungsdaten"
},
"confirmation_step": {
"title": "Bestätigung",
"subtitle": "Alles erledigt!",
"description": "Ihr Paket ist aktiviert. Überprüfen Sie Ihre E-Mail für Details.",
"welcome": "Willkommen bei Fotospiel",
"package_activated": "Ihr Paket '{name}' ist aktiviert.",
"email_sent": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet.",
"open_profile": "Profil öffnen",
"to_admin": "Zum Admin-Bereich"
},
"confirmation": {
"welcome": "Willkommen bei Fotospiel",
"package_activated": "Ihr Paket '{name}' ist aktiviert.",
"email_sent": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet.",
"open_profile": "Profil öffnen",
"to_admin": "Zum Admin-Bereich"
},
"auth": {
"already_logged_in": "Sie sind bereits als {email} eingeloggt.",
"switch_to_register": "Registrieren",
"switch_to_login": "Anmelden",
"google_coming_soon": "Google-Login kommt bald im Comfort-Delta."
}
}
}

View File

@@ -20,8 +20,13 @@
"failed_credentials": "Invalid credentials.",
"login": {
"title": "Login",
"username_or_email": "Username or Email",
"identifier": "Email or Username",
"identifier_placeholder": "Enter your email or username",
"email": "Email",
"email_placeholder": "Enter your email",
"password": "Password",
"password_placeholder": "Enter your password",
"forgot": "Forgot Password?",
"remember": "Remember me",
"submit": "Login"
},
@@ -64,6 +69,68 @@
"privacy_consent": "Privacy Consent"
}
},
"common": {
"ui": {
"language_select": "Select Language"
}
},
"settings": {
"profile": {
"title": "Profile settings",
"section_title": "Profile information",
"description": "Update your name and email address",
"email": "Email address",
"email_placeholder": "Email address",
"username": "Username",
"username_placeholder": "Username",
"language": "Language",
"email_unverified": "Your email address is unverified.",
"resend_verification": "Click here to resend the verification email.",
"verification_sent": "A new verification link has been sent to your email address."
},
"password": {
"title": "Password settings",
"section_title": "Update password",
"description": "Ensure your account is using a long, random password to stay secure",
"current": "Current password",
"current_placeholder": "Current password",
"new": "New password",
"new_placeholder": "New password",
"confirm": "Confirm password",
"confirm_placeholder": "Confirm password",
"save_button": "Save password"
}
},
"reset": {
"password": "Password",
"password_placeholder": "Password",
"confirm_password": "Confirm password",
"confirm_password_placeholder": "Confirm password",
"email": "Email",
"email_placeholder": "email@example.com",
"title": "Reset password",
"description": "Please enter your new password below",
"submit": "Reset password"
},
"confirm": {
"password": "Password",
"password_placeholder": "Password",
"confirm": "Confirm password",
"title": "Confirm your password",
"description": "This is a secure area of the application. Please confirm your password before continuing.",
"submit": "Confirm password"
},
"forgot": {
"email": "Email address",
"email_placeholder": "email@example.com",
"submit": "Email password reset link",
"back": "Or, return to log in"
},
"delete_user": {
"password": "Password",
"password_placeholder": "Password",
"confirm_text": "Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account."
},
"verification": {
"notice": "Please confirm your email address.",
"resend": "Resend email"

View File

@@ -31,5 +31,24 @@
"lisa": {
"name": "Lisa K."
}
},
"ui": {
"saved": "Saved",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"please_enter": "Please enter",
"click_here": "Click here",
"resend_verification": "Resend verification email",
"verify_email": "Verify Email",
"check_email": "Check your email for the verification link.",
"login_to_continue": "Log in to continue.",
"no_account": "No Account? Register",
"already_registered": "Already registered? Log in",
"redirecting": "Redirecting to Admin Area...",
"complete_purchase": "Complete Purchase",
"email_verify_title": "Verify Email",
"email_verify_desc": "Please check your email and click the verification link.",
"language_select": "Language Select"
}
}

View File

@@ -279,6 +279,14 @@
"privacy": "Privacy",
"impressum": "Imprint"
},
"header": {
"home": "Home",
"packages": "Packages",
"blog": "Blog",
"contact": "Contact",
"login": "Login",
"register": "Register"
},
"footer": {
"company": "Fotospiel GmbH",
"rights_reserved": "All Rights Reserved"
@@ -324,5 +332,82 @@
"name": "Lisa K."
}
}
},
"checkout": {
"title": "Checkout",
"subtitle": "Secure Checkout Process",
"back": "Back",
"next": "Next",
"cancel": "Cancel",
"package_step": {
"title": "Select Package",
"subtitle": "Selection and Comparison",
"description": "Choose the right package for your needs",
"no_package_selected": "No package selected. Please choose a package from the overview.",
"alternatives_title": "Alternative Packages",
"no_alternatives": "No further packages in this category available.",
"next_to_account": "Next to Account",
"loading": "Loading..."
},
"auth_step": {
"title": "Account",
"subtitle": "Login or Register",
"description": "Create an account or log in to continue with your purchase.",
"already_logged_in_title": "Already Logged In",
"already_logged_in_desc": "You are already logged in as {email}.",
"next_to_payment": "Next to Payment",
"switch_to_register": "Register",
"switch_to_login": "Login",
"google_coming_soon": "Google Login coming soon in Comfort-Delta."
},
"payment_step": {
"title": "Payment",
"subtitle": "Secure Payment",
"description": "Complete your purchase securely with your chosen payment method.",
"free_package_title": "Free Package",
"free_package_desc": "This package is free. We activate it directly after confirmation.",
"activate_package": "Activate Package",
"loading_payment": "Payment data is loading...",
"secure_payment_desc": "Secure payment with credit card, debit card or SEPA direct debit.",
"payment_failed": "Payment failed. ",
"error_card": "Card error occurred.",
"error_validation": "Input data is invalid.",
"error_connection": "Connection error. Please check your internet connection.",
"error_server": "Server error. Please try again later.",
"error_auth": "Authentication error. Please reload the page.",
"error_unknown": "Unknown error occurred.",
"processing": "Payment is being processed. Please wait...",
"needs_method": "Payment method required. Please check card details.",
"needs_confirm": "Payment needs confirmation.",
"unexpected_status": "Unexpected payment status: {status}",
"processing_btn": "Processing...",
"pay_now": "Pay Now (${price})",
"stripe_not_loaded": "Stripe is not initialized. Please reload the page.",
"network_error": "Network error loading payment data",
"payment_intent_error": "Error loading payment data"
},
"confirmation_step": {
"title": "Confirmation",
"subtitle": "All Done!",
"description": "Your package is activated. Check your email for details.",
"welcome": "Welcome to FotoSpiel",
"package_activated": "Your package '{name}' is activated.",
"email_sent": "We have sent you a confirmation email.",
"open_profile": "Open Profile",
"to_admin": "To Admin Area"
},
"confirmation": {
"welcome": "Welcome to FotoSpiel",
"package_activated": "Your package '{name}' is activated.",
"email_sent": "We have sent you a confirmation email.",
"open_profile": "Open Profile",
"to_admin": "To Admin Area"
},
"auth": {
"already_logged_in": "You are already logged in as {email}.",
"switch_to_register": "Register",
"switch_to_login": "Login",
"google_coming_soon": "Google Login coming soon in Comfort-Delta."
}
}
}

View File

@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Form } from '@inertiajs/react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
export default function DeleteUser() {
const { t } = useTranslation('auth');
const passwordInput = useRef<HTMLInputElement>(null);
return (
@@ -27,8 +29,7 @@ export default function DeleteUser() {
<DialogContent>
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
<DialogDescription>
Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password
to confirm you would like to permanently delete your account.
{t('auth.delete_user.confirm_text')}
</DialogDescription>
<Form
@@ -52,7 +53,7 @@ export default function DeleteUser() {
type="password"
name="password"
ref={passwordInput}
placeholder="Password"
placeholder={t('auth.delete_user.password_placeholder')}
autoComplete="current-password"
/>

View File

@@ -24,7 +24,7 @@ const Header: React.FC = () => {
};
const handleLanguageChange = useCallback(async (value: string) => {
console.log('handleLanguageChange called with:', value);
//console.log('handleLanguageChange called with:', value);
try {
const response = await fetch('/set-locale', {
method: 'POST',
@@ -35,9 +35,9 @@ const Header: React.FC = () => {
body: JSON.stringify({ locale: value }),
});
console.log('fetch response:', response.status);
//console.log('fetch response:', response.status);
if (response.ok) {
console.log('calling i18n.changeLanguage with:', value);
//console.log('calling i18n.changeLanguage with:', value);
i18n.changeLanguage(value);
// Reload only the locale prop to update the page props
router.reload({ only: ['locale'] });
@@ -117,7 +117,7 @@ const Header: React.FC = () => {
</Button>
<Select value={i18n.language || 'de'} onValueChange={handleLanguageChange}>
<SelectTrigger className="w-[70px] h-8">
<SelectValue placeholder="DE" />
<SelectValue placeholder={t('common.ui.language_select')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="de">DE</SelectItem>

View File

@@ -43,7 +43,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
const loginEndpoint = '/checkout/login';
const [values, setValues] = useState({
email: "",
identifier: "",
password: "",
remember: false,
});
@@ -98,7 +98,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
},
credentials: "same-origin",
body: JSON.stringify({
email: values.email,
identifier: values.identifier,
password: values.password,
remember: values.remember,
locale: resolvedLocale,
@@ -146,18 +146,18 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
<form onSubmit={submit} className="flex flex-col gap-6" noValidate>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">{t("login.email")}</Label>
<Label htmlFor="identifier">{t("login.identifier") || t("login.email")}</Label>
<Input
id="email"
type="email"
name="email"
id="identifier"
type="text"
name="identifier"
required
autoFocus
placeholder={t("login.email_placeholder")}
value={values.email}
onChange={(event) => updateValue("email", event.target.value)}
placeholder={t("login.identifier_placeholder") || t("login.email_placeholder")}
value={values.identifier}
onChange={(event) => updateValue("identifier", event.target.value)}
/>
<InputError message={errors.email} />
<InputError message={errors.identifier || errors.email} />
</div>
<div className="grid gap-2">

View File

@@ -6,21 +6,23 @@ import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth-layout';
import { Form, Head } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function ConfirmPassword() {
const { t } = useTranslation('auth');
return (
<AuthLayout
title="Confirm your password"
description="This is a secure area of the application. Please confirm your password before continuing."
title={t('auth.confirm.title', 'Confirm your password')}
description={t('auth.confirm.description', 'This is a secure area of the application. Please confirm your password before continuing.')}
>
<Head title="Confirm password" />
<Head title={t('auth.confirm.title', 'Confirm password')} />
<Form {...store.form()} resetOnSuccess={['password']}>
{({ processing, errors }) => (
<div className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" name="password" placeholder="Password" autoComplete="current-password" autoFocus />
<Label htmlFor="password">{t('auth.confirm.password', 'Password')}</Label>
<Input id="password" type="password" name="password" placeholder={t('auth.confirm.password_placeholder')} autoComplete="current-password" autoFocus />
<InputError message={errors.password} />
</div>
@@ -28,7 +30,7 @@ export default function ConfirmPassword() {
<div className="flex items-center">
<Button className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Confirm password
{t('auth.confirm.submit', 'Confirm password')}
</Button>
</div>
</div>

View File

@@ -12,9 +12,10 @@ import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth-layout';
export default function ForgotPassword({ status }: { status?: string }) {
const { t } = useTranslation('auth');
return (
<AuthLayout title="Forgot password" description="Enter your email to receive a password reset link">
<Head title="Forgot password" />
<AuthLayout title={t('auth.forgot.title', 'Forgot password')} description={t('auth.forgot.description', 'Enter your email to receive a password reset link')}>
<Head title={t('auth.forgot.title', 'Forgot password')} />
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
@@ -24,7 +25,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
<>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input id="email" type="email" name="email" autoComplete="off" autoFocus placeholder="email@example.com" />
<Input id="email" type="email" name="email" autoComplete="off" autoFocus placeholder={t('auth.forgot.email_placeholder')} />
<InputError message={errors.email} />
</div>
@@ -41,7 +42,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
<div className="space-x-1 text-center text-sm text-muted-foreground">
<span>Or, return to</span>
<TextLink href={login()}>log in</TextLink>
<TextLink href={login()}>{t('auth.forgot.back')}</TextLink>
</div>
</div>
</AuthLayout>

View File

@@ -1,6 +1,7 @@
import { store } from '@/actions/App/Http/Controllers/Auth/NewPasswordController';
import { Form, Head } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
@@ -14,25 +15,26 @@ interface ResetPasswordProps {
}
export default function ResetPassword({ token, email }: ResetPasswordProps) {
const { t } = useTranslation('auth');
return (
<AuthLayout title="Reset password" description="Please enter your new password below">
<Head title="Reset password" />
<AuthLayout title={t('auth.reset.title', 'Reset password')} description={t('auth.reset.description', 'Please enter your new password below')}>
<Head title={t('auth.reset.title', 'Reset password')} />
<Form
{...NewPasswordController.store.form()}
{...store.form()}
transform={(data) => ({ ...data, token, email })}
resetOnSuccess={['password', 'password_confirmation']}
>
{({ processing, errors }) => (
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email">{t('auth.reset.email')}</Label>
<Input id="email" type="email" name="email" autoComplete="email" value={email} className="mt-1 block w-full" readOnly />
<InputError message={errors.email} className="mt-2" />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t('auth.reset.password', 'Password')}</Label>
<Input
id="password"
type="password"
@@ -40,27 +42,27 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
autoComplete="new-password"
className="mt-1 block w-full"
autoFocus
placeholder="Password"
placeholder={t('auth.reset.password_placeholder')}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Label htmlFor="password_confirmation">{t('auth.reset.confirm_password', 'Confirm password')}</Label>
<Input
id="password_confirmation"
type="password"
name="password_confirmation"
autoComplete="new-password"
className="mt-1 block w-full"
placeholder="Confirm password"
placeholder={t('auth.reset.confirm_password_placeholder')}
/>
<InputError message={errors.password_confirmation} className="mt-2" />
</div>
<Button type="submit" className="mt-4 w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Reset password
{t('auth.reset.submit', 'Reset password')}
</Button>
</div>
)}

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from "react";
import { useTranslation } from 'react-i18next';
import { Steps } from "@/components/ui/Steps";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
@@ -23,36 +24,45 @@ interface CheckoutWizardProps {
initialStep?: CheckoutStepId;
}
const stepConfig: { id: CheckoutStepId; title: string; description: string; details: string }[] = [
const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: string; detailsKey: string }[] = [
{
id: "package",
title: "Paket wählen",
description: "Auswahl und Vergleich",
details: "Wähle das passende Paket für deine Bedürfnisse"
titleKey: 'checkout.package_step.title',
descriptionKey: 'checkout.package_step.subtitle',
detailsKey: 'checkout.package_step.description'
},
{
id: "auth",
title: "Konto einrichten",
description: "Login oder Registrierung",
details: "Erstelle ein Konto oder melde dich an"
titleKey: 'checkout.auth_step.title',
descriptionKey: 'checkout.auth_step.subtitle',
detailsKey: 'checkout.auth_step.description'
},
{
id: "payment",
title: "Bezahlung",
description: "Sichere Zahlung",
details: "Gib deine Zahlungsdaten ein"
titleKey: 'checkout.payment_step.title',
descriptionKey: 'checkout.payment_step.subtitle',
detailsKey: 'checkout.payment_step.description'
},
{
id: "confirmation",
title: "Fertig!",
description: "Zugang aktiv",
details: "Dein Paket ist aktiviert"
titleKey: 'checkout.confirmation_step.title',
descriptionKey: 'checkout.confirmation_step.subtitle',
detailsKey: 'checkout.confirmation_step.description'
},
];
const WizardBody: React.FC<{ stripePublishableKey: string; privacyHtml: string }> = ({ stripePublishableKey, privacyHtml }) => {
const { t } = useTranslation('marketing');
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
const stepConfig = useMemo(() =>
baseStepConfig.map(step => ({
id: step.id,
title: t(step.titleKey),
description: t(step.descriptionKey),
details: t(step.detailsKey),
})),
[t]
);
const currentIndex = useMemo(() => stepConfig.findIndex((step) => step.id === currentStep), [currentStep]);
const progress = useMemo(() => {
@@ -60,7 +70,7 @@ const WizardBody: React.FC<{ stripePublishableKey: string; privacyHtml: string }
return 0;
}
return (currentIndex / (stepConfig.length - 1)) * 100;
}, [currentIndex]);
}, [currentIndex, stepConfig]);
return (
<div className="space-y-8">
@@ -78,10 +88,10 @@ const WizardBody: React.FC<{ stripePublishableKey: string; privacyHtml: string }
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={previousStep} disabled={currentIndex <= 0}>
Zurueck
{t('checkout.back')}
</Button>
<Button onClick={nextStep} disabled={currentIndex >= stepConfig.length - 1}>
Weiter
{t('checkout.next')}
</Button>
</div>
</div>

View File

@@ -124,14 +124,18 @@ export function CheckoutWizardProvider({
if (savedState) {
try {
const parsed = JSON.parse(savedState);
if (parsed.selectedPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') {
// Restore state selectively
if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
if (parsed.currentStep) dispatch({ type: 'GO_TO_STEP', payload: parsed.currentStep });
} else {
localStorage.removeItem('checkout-wizard-state');
}
} catch (error) {
console.error('Failed to restore checkout state:', error);
}
}
}, []);
}, [initialPackage]);
// Save state to localStorage whenever it changes
useEffect(() => {
@@ -141,6 +145,13 @@ export function CheckoutWizardProvider({
}));
}, [state.selectedPackage, state.currentStep]);
// Clear localStorage when confirmation step is reached
useEffect(() => {
if (state.currentStep === 'confirmation') {
localStorage.removeItem('checkout-wizard-state');
}
}, [state.currentStep]);
const selectPackage = useCallback((pkg: CheckoutPackage) => {
dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
}, []);

View File

@@ -5,12 +5,14 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
import { useTranslation } from 'react-i18next';
interface AuthStepProps {
privacyHtml: string;
}
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
const { t } = useTranslation('marketing');
const page = usePage<{ locale?: string }>();
const locale = page.props.locale ?? "de";
const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard();
@@ -48,14 +50,14 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Bereits eingeloggt</AlertTitle>
<AlertTitle>{t('checkout.auth_step.already_logged_in_title')}</AlertTitle>
<AlertDescription>
{authUser.email ? `Sie sind als ${authUser.email} angemeldet.` : "Sie sind bereits angemeldet."}
{t('checkout.auth_step.already_logged_in_desc', { email: authUser?.email || '' })}
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
Weiter zur Zahlung
{t('checkout.auth_step.next_to_payment')}
</Button>
</div>
</div>
@@ -69,16 +71,16 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
variant={mode === 'register' ? 'default' : 'outline'}
onClick={() => setMode('register')}
>
Registrieren
{t('checkout.auth_step.switch_to_register')}
</Button>
<Button
variant={mode === 'login' ? 'default' : 'outline'}
onClick={() => setMode('login')}
>
Anmelden
{t('checkout.auth_step.switch_to_login')}
</Button>
<span className="text-xs text-muted-foreground">
Google Login folgt im Komfort-Delta.
{t('checkout.auth_step.google_coming_soon')}
</span>
</div>

View File

@@ -2,27 +2,30 @@ import React from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
import { useTranslation } from 'react-i18next';
interface ConfirmationStepProps {
onViewProfile?: () => void;
}
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile }) => {
const { t } = useTranslation('marketing');
const { selectedPackage } = useCheckoutWizard();
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Willkommen bei FotoSpiel</AlertTitle>
<AlertTitle>{t('checkout.confirmation_step.welcome')}</AlertTitle>
<AlertDescription>
Ihr Paket "{selectedPackage.name}" ist aktiviert. Wir haben Ihnen eine Bestätigung per E-Mail gesendet.
{t('checkout.confirmation_step.package_activated', { name: selectedPackage?.name || '' })}
{t('checkout.confirmation_step.email_sent')}
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-3 justify-end">
<Button variant="outline" onClick={onViewProfile}>
Profil oeffnen
{t('checkout.confirmation_step.open_profile')}
</Button>
<Button>Zum Admin-Bereich</Button>
<Button>{t('checkout.confirmation_step.to_admin')}</Button>
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from 'react-i18next';
import { Check, Package as PackageIcon, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -77,6 +78,7 @@ function PackageOption({ pkg, isActive, onSelect }: { pkg: CheckoutPackage; isAc
}
export const PackageStep: React.FC = () => {
const { t } = useTranslation('marketing');
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState, nextStep } = useCheckoutWizard();
const [isLoading, setIsLoading] = useState(false);
@@ -85,7 +87,7 @@ export const PackageStep: React.FC = () => {
if (!selectedPackage) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Kein Paket ausgewählt. Bitte wähle ein Paket aus der Paketübersicht.</p>
<p className="text-muted-foreground">{t('checkout.package_step.no_package_selected')}</p>
</div>
);
}
@@ -129,17 +131,17 @@ export const PackageStep: React.FC = () => {
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird geladen...
{t('checkout.package_step.loading')}
</>
) : (
"Weiter zum Konto"
t('checkout.package_step.next_to_account')
)}
</Button>
</div>
</div>
<aside className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Alternative Pakete
{t('checkout.package_step.alternatives_title')}
</h3>
<div className="space-y-3">
{comparablePackages.map((pkg) => (
@@ -152,7 +154,7 @@ export const PackageStep: React.FC = () => {
))}
{comparablePackages.length === 0 && (
<p className="text-xs text-muted-foreground">
Keine weiteren Pakete in dieser Kategorie verfuegbar.
{t('checkout.package_step.no_alternatives')}
</p>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { Button } from "@/components/ui/button";
@@ -14,6 +15,7 @@ const PaymentForm: React.FC = () => {
const stripe = useStripe();
const elements = useElements();
const { selectedPackage, resetPaymentState, nextStep } = useCheckoutWizard();
const { t } = useTranslation('marketing');
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string>('');
@@ -27,7 +29,7 @@ const PaymentForm: React.FC = () => {
event.preventDefault();
if (!stripe || !elements) {
setError('Stripe ist nicht initialisiert. Bitte Seite neu laden.');
setError(t('checkout.payment_step.stripe_not_loaded'));
return;
}
@@ -46,26 +48,26 @@ const PaymentForm: React.FC = () => {
if (stripeError) {
console.error('Stripe Payment Error:', stripeError);
let errorMessage = 'Zahlung fehlgeschlagen. ';
let errorMessage = t('checkout.payment_step.payment_failed');
switch (stripeError.type) {
case 'card_error':
errorMessage += stripeError.message || 'Kartenfehler aufgetreten.';
errorMessage += stripeError.message || t('checkout.payment_step.error_card');
break;
case 'validation_error':
errorMessage += 'Eingabedaten sind ungültig.';
errorMessage += t('checkout.payment_step.error_validation');
break;
case 'api_connection_error':
errorMessage += 'Verbindungsfehler. Bitte Internetverbindung prüfen.';
errorMessage += t('checkout.payment_step.error_connection');
break;
case 'api_error':
errorMessage += 'Serverfehler. Bitte später erneut versuchen.';
errorMessage += t('checkout.payment_step.error_server');
break;
case 'authentication_error':
errorMessage += 'Authentifizierungsfehler. Bitte Seite neu laden.';
errorMessage += t('checkout.payment_step.error_auth');
break;
default:
errorMessage += stripeError.message || 'Unbekannter Fehler aufgetreten.';
errorMessage += stripeError.message || t('checkout.payment_step.error_unknown');
}
setError(errorMessage);
@@ -78,25 +80,25 @@ const PaymentForm: React.FC = () => {
setTimeout(() => nextStep(), 1000);
break;
case 'processing':
setError('Zahlung wird verarbeitet. Bitte warten...');
setError(t('checkout.payment_step.processing'));
setPaymentStatus('processing');
break;
case 'requires_payment_method':
setError('Zahlungsmethode wird benötigt. Bitte Kartendaten überprüfen.');
setError(t('checkout.payment_step.needs_method'));
setPaymentStatus('failed');
break;
case 'requires_confirmation':
setError('Zahlung muss bestätigt werden.');
setError(t('checkout.payment_step.needs_confirm'));
setPaymentStatus('failed');
break;
default:
setError(`Unerwarteter Zahlungsstatus: ${paymentIntent.status}`);
setError(t('checkout.payment_step.unexpected_status', { status: paymentIntent.status }));
setPaymentStatus('failed');
}
}
} catch (err) {
console.error('Unexpected payment error:', err);
setError('Unerwarteter Fehler aufgetreten. Bitte später erneut versuchen.');
setError(t('checkout.payment_step.error_unknown'));
setPaymentStatus('failed');
} finally {
setIsProcessing(false);
@@ -114,7 +116,7 @@ const PaymentForm: React.FC = () => {
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
<p className="text-sm text-muted-foreground">
Sichere Zahlung mit Kreditkarte, Debitkarte oder SEPA-Lastschrift.
{t('checkout.payment_step.secure_payment_desc')}
</p>
<PaymentElement />
<Button
@@ -123,7 +125,7 @@ const PaymentForm: React.FC = () => {
size="lg"
className="w-full"
>
{isProcessing ? 'Verarbeitung...' : `Jetzt bezahlen (€${selectedPackage?.price || 0})`}
{isProcessing ? t('checkout.payment_step.processing_btn') : t('checkout.payment_step.pay_now', { price: selectedPackage?.price || 0 })}
</Button>
</div>
</form>
@@ -133,13 +135,14 @@ const PaymentForm: React.FC = () => {
// Wrapper-Komponente mit eigenem Elements Provider
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey }) => {
const { t } = useTranslation('marketing');
const { selectedPackage, authUser, nextStep } = useCheckoutWizard();
const [clientSecret, setClientSecret] = useState<string>('');
const [error, setError] = useState<string>('');
const isFree = selectedPackage ? selectedPackage.price <= 0 : false;
// Payment Intent für kostenpflichtige Pakete laden
// Payment Intent für kostenpflichtige Pakete laden
useEffect(() => {
if (isFree || !authUser || !selectedPackage) return;
@@ -168,31 +171,31 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey }
setClientSecret(data.client_secret);
setError('');
} else {
const errorMsg = data.error || 'Fehler beim Laden der Zahlungsdaten';
const errorMsg = data.error || t('checkout.payment_step.payment_intent_error');
console.error('Payment Intent Error:', errorMsg);
setError(errorMsg);
}
} catch (err) {
setError('Netzwerkfehler beim Laden der Zahlungsdaten');
setError(t('checkout.payment_step.network_error'));
}
};
loadPaymentIntent();
}, [selectedPackage?.id, authUser, isFree]);
}, [selectedPackage?.id, authUser, isFree, t]);
// Für kostenlose Pakete: Direkte Aktivierung ohne Stripe
if (isFree) {
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Kostenloses Paket</AlertTitle>
<AlertTitle>{t('checkout.payment_step.free_package_title')}</AlertTitle>
<AlertDescription>
Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.
{t('checkout.payment_step.free_package_desc')}
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
Paket aktivieren
{t('checkout.payment_step.activate_package')}
</Button>
</div>
</div>
@@ -210,7 +213,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey }
)}
<div className="rounded-lg border bg-card p-6 shadow-sm">
<p className="text-sm text-muted-foreground">
Zahlungsdaten werden geladen...
{t('checkout.payment_step.loading_payment')}
</p>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { type BreadcrumbItem } from '@/types';
import { Transition } from '@headlessui/react';
import { Form, Head } from '@inertiajs/react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import HeadingSmall from '@/components/heading-small';
import { Button } from '@/components/ui/button';
@@ -21,19 +22,20 @@ const breadcrumbs: BreadcrumbItem[] = [
];
export default function Password() {
const { t } = useTranslation('auth');
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Password settings" />
<Head title={t('auth.settings.password.title', 'Password settings')} />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall title="Update password" description="Ensure your account is using a long, random password to stay secure" />
<HeadingSmall title={t('auth.settings.password.section_title', 'Update password')} description={t('auth.settings.password.description', 'Ensure your account is using a long, random password to stay secure')} />
<Form
{...PasswordController.update.form()}
{...store.form()}
options={{
preserveScroll: true,
}}
@@ -53,7 +55,7 @@ export default function Password() {
{({ errors, processing, recentlySuccessful }) => (
<>
<div className="grid gap-2">
<Label htmlFor="current_password">Current password</Label>
<Label htmlFor="current_password">{t('auth.settings.password.current', 'Current password')}</Label>
<Input
id="current_password"
@@ -62,14 +64,14 @@ export default function Password() {
type="password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
placeholder={t('auth.settings.password.current_placeholder')}
/>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<Label htmlFor="password">{t('auth.settings.password.new', 'New password')}</Label>
<Input
id="password"
@@ -78,14 +80,14 @@ export default function Password() {
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
placeholder={t('auth.settings.password.new_placeholder')}
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Label htmlFor="password_confirmation">{t('auth.settings.password.confirm', 'Confirm password')}</Label>
<Input
id="password_confirmation"
@@ -93,14 +95,14 @@ export default function Password() {
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
placeholder={t('auth.settings.password.confirm_placeholder')}
/>
<InputError message={errors.password_confirmation} />
</div>
<div className="flex items-center gap-4">
<Button disabled={processing}>Save password</Button>
<Button disabled={processing}>{t('auth.settings.password.save_button', 'Save password')}</Button>
<Transition
show={recentlySuccessful}
@@ -109,7 +111,7 @@ export default function Password() {
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-neutral-600">Saved</p>
<p className="text-sm text-neutral-600">{t('common.ui.saved')}</p>
</Transition>
</div>
</>

View File

@@ -3,6 +3,7 @@ import { send } from '@/routes/verification';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { Transition } from '@headlessui/react';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import DeleteUser from '@/components/delete-user';
import HeadingSmall from '@/components/heading-small';
@@ -22,15 +23,16 @@ const breadcrumbs: BreadcrumbItem[] = [
];
export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) {
const { t } = useTranslation('auth');
const { auth, supportedLocales } = usePage<SharedData>().props as SharedData & { supportedLocales: string[] };
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Profile settings" />
<Head title={t('auth.settings.profile.title', 'Profile settings')} />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall title="Profile information" description="Update your name and email address" />
<HeadingSmall title={t('auth.settings.profile.section_title', 'Profile information')} description={t('auth.settings.profile.description', 'Update your name and email address')} />
<Form
{...ProfileController.update.form()}
@@ -42,7 +44,7 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
{({ processing, recentlySuccessful, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">{t('auth.settings.profile.email', 'Email address')}</Label>
<Input
id="email"
@@ -52,14 +54,14 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
name="email"
required
autoComplete="username"
placeholder="Email address"
placeholder={t('auth.settings.profile.email_placeholder')}
/>
<InputError className="mt-2" message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">{t('auth.settings.profile.username', 'Username')}</Label>
<Input
id="username"
@@ -67,14 +69,14 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
defaultValue={(auth.user as any).username ?? ''}
name="username"
autoComplete="username"
placeholder="Username"
placeholder={t('auth.settings.profile.username_placeholder')}
/>
<InputError className="mt-2" message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="preferred_locale">Language</Label>
<Label htmlFor="preferred_locale">{t('auth.settings.profile.language', 'Language')}</Label>
<select
id="preferred_locale"
@@ -95,26 +97,26 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
{mustVerifyEmail && auth.user.email_verified_at === null && (
<div>
<p className="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.{' '}
{t('auth.settings.profile.email_unverified', 'Your email address is unverified.')} {' '}
<Link
href={send()}
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to resend the verification email.
{t('auth.settings.profile.resend_verification', 'Click here to resend the verification email.')}
</Link>
</p>
{status === 'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
A new verification link has been sent to your email address.
{t('auth.settings.profile.verification_sent', 'A new verification link has been sent to your email address.')}
</div>
)}
</div>
)}
<div className="flex items-center gap-4">
<Button disabled={processing}>Save</Button>
<Button disabled={processing}>{t('common.ui.save', 'Save')}</Button>
<Transition
show={recentlySuccessful}
@@ -123,7 +125,7 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-neutral-600">Saved</p>
<p className="text-sm text-neutral-600">{t('common.ui.saved', 'Saved')}</p>
</Transition>
</div>
</>

View File

@@ -2,6 +2,7 @@
use App\Http\Controllers\MarketingController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\LocaleController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@@ -43,3 +44,4 @@ Route::post('/checkout/register', [CheckoutController::class, 'register'])->name
Route::post('/stripe/create-payment-intent', [CheckoutController::class, 'createPaymentIntent'])->name('stripe.create-payment-intent');
Route::post('/stripe/confirm-payment', [CheckoutController::class, 'confirmPayment'])->name('stripe.confirm-payment');
Route::post('/checkout/track-abandoned', [CheckoutController::class, 'trackAbandonedCheckout'])->name('checkout.track-abandoned');
Route::post('/set-locale', [LocaleController::class, 'set'])->name('set-locale');

View File

@@ -13,34 +13,48 @@ class CheckoutAuthTest extends TestCase
public function test_checkout_login_returns_json_response_with_valid_credentials()
{
$user = User::factory()->create();
$user = User::factory()->create(['pending_purchase' => false]);
$package = Package::factory()->create();
$this->actingAs($user); // To simulate session, but for login test, guest
$response = $this->postJson(route('checkout.login'), [
'login' => $user->email,
'identifier' => $user->email,
'password' => 'password',
'remember' => false,
'locale' => 'de',
]);
$response->assertStatus(200)
->assertJsonStructure([
'user' => [
'id',
'email',
'name',
'pending_purchase',
],
'message',
])
->assertJson([
'success' => true,
'message' => 'Login erfolgreich',
'user' => [
'id' => $user->id,
'email' => $user->email,
'pending_purchase' => false,
'pending_purchase' => true, // Set by logic
],
]);
$this->assertAuthenticatedAs($user);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'pending_purchase' => true,
]);
}
public function test_checkout_login_returns_validation_errors_with_invalid_credentials()
{
$user = User::factory()->create();
$response = $this->postJson(route('checkout.login'), [
'login' => $user->email,
'identifier' => 'invalid@example.com',
'password' => 'wrong-password',
'remember' => false,
'locale' => 'de',
@@ -49,35 +63,49 @@ class CheckoutAuthTest extends TestCase
$response->assertStatus(422)
->assertJsonStructure([
'errors' => [
'login' => [],
'identifier' => [],
],
]);
])
->assertJsonPath('errors.identifier.0', 'Ungültige Anmeldedaten.');
$this->assertGuest();
}
public function test_checkout_login_with_username()
{
$user = User::factory()->create(['username' => 'testuser']);
$user = User::factory()->create(['username' => 'testuser', 'pending_purchase' => false]);
$response = $this->postJson(route('checkout.login'), [
'login' => 'testuser', // Using username as login field
'identifier' => 'testuser',
'password' => 'password',
'remember' => false,
'locale' => 'de',
]);
$response->assertStatus(200)
->assertJsonStructure([
'user' => [
'id',
'email',
'name',
'pending_purchase',
],
'message',
])
->assertJson([
'success' => true,
'message' => 'Login erfolgreich',
'user' => [
'id' => $user->id,
'email' => $user->email,
'pending_purchase' => false,
'username' => 'testuser',
'pending_purchase' => true,
],
]);
$this->assertAuthenticatedAs($user);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'pending_purchase' => true,
]);
}
public function test_checkout_register_creates_user_and_tenant_successfully()