diff --git a/.env.example b/.env.example
index b879ee412..57423f748 100644
--- a/.env.example
+++ b/.env.example
@@ -37,12 +37,20 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
-MAIL_DRIVER=smtp
+# Set to 0 to disable throttle during local development (default: 60 seconds)
+AUTH_PASSWORD_RESET_THROTTLE=60
+
+MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
+MAIL_FROM_ADDRESS="noreply@ogamex.dev"
+MAIL_FROM_NAME="${APP_NAME}"
+
+# To use Gmail: MAIL_HOST=smtp.gmail.com, MAIL_PORT=587, MAIL_ENCRYPTION=tls
+# Other providers: Mailgun, Postmark, Resend — configure MAIL_HOST/PORT/CREDENTIALS above.
REVERB_APP_ID=ogamex
REVERB_APP_KEY=ogamex-key
diff --git a/app/Http/Controllers/ForgotEmailController.php b/app/Http/Controllers/ForgotEmailController.php
new file mode 100644
index 000000000..b37bf6a89
--- /dev/null
+++ b/app/Http/Controllers/ForgotEmailController.php
@@ -0,0 +1,50 @@
+validate([
+ 'username' => ['required', 'string', 'max:255'],
+ ]);
+
+ // Always show a success message regardless of whether the username exists,
+ // to avoid leaking which usernames are registered.
+ $user = User::where('username', $request->username)->first();
+
+ if ($user !== null) {
+ $maskedEmail = $this->maskEmail($user->email);
+ Mail::to($user->email)->send(new RetrieveEmailMail(
+ maskedEmail: $maskedEmail,
+ username: $user->username,
+ loginUrl: route('login'),
+ ));
+ }
+
+ return redirect()->route('password.email-lookup')
+ ->with('status', __('t_external.forgot_email.sent'));
+ }
+
+ private function maskEmail(string $email): string
+ {
+ [$local, $domain] = explode('@', $email, 2);
+ $visibleLocal = mb_substr($local, 0, min(2, mb_strlen($local)));
+ $masked = $visibleLocal . str_repeat('*', max(0, mb_strlen($local) - 2));
+
+ return $masked . '@' . $domain;
+ }
+}
diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php
new file mode 100644
index 000000000..56a80b543
--- /dev/null
+++ b/app/Mail/ResetPasswordMail.php
@@ -0,0 +1,33 @@
+ $token,
+ 'email' => $this->email,
+ ], false));
+
+ Mail::to($this->email)->send(new ResetPasswordMail($resetUrl, $this->username));
+ }
}
diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php
index 766c67fde..7580f1ad9 100644
--- a/app/Providers/FortifyServiceProvider.php
+++ b/app/Providers/FortifyServiceProvider.php
@@ -46,6 +46,14 @@ public function boot(): void
return Limit::perMinute(20)->by($request->session()->get('login.id'));
});
+ RateLimiter::for('forgot-email', function (Request $request) {
+ if (app()->environment('testing')) {
+ return Limit::none();
+ }
+
+ return Limit::perMinute(5)->by($request->ip());
+ });
+
Fortify::loginView(function () {
return view('outgame.login');
});
@@ -71,16 +79,12 @@ public function boot(): void
return $user;
});
- /*Fortify::registerView(function () {
- return view('auth.register');
- });
-
Fortify::requestPasswordResetLinkView(function () {
- return view('auth.forgot-password');
+ return view('outgame.forgot-password');
});
- Fortify::resetPasswordView(function () {
- return view('auth.reset-password');
- });*/
+ Fortify::resetPasswordView(function ($request) {
+ return view('outgame.reset-password', ['request' => $request]);
+ });
}
}
diff --git a/config/auth.php b/config/auth.php
index 5531825e2..e4c635cdb 100644
--- a/config/auth.php
+++ b/config/auth.php
@@ -95,7 +95,7 @@
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
- 'throttle' => 60,
+ 'throttle' => env('AUTH_PASSWORD_RESET_THROTTLE', 60),
],
],
diff --git a/database/migrations/2026_04_05_193349_create_password_reset_tokens_table.php b/database/migrations/2026_04_05_193349_create_password_reset_tokens_table.php
new file mode 100644
index 000000000..81a7229b0
--- /dev/null
+++ b/database/migrations/2026_04_05_193349_create_password_reset_tokens_table.php
@@ -0,0 +1,28 @@
+string('email')->primary();
+ $table->string('token');
+ $table->timestamp('created_at')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('password_reset_tokens');
+ }
+};
diff --git a/public/build/assets/outgame-b0a7e11e.js b/public/build/assets/outgame-b0a7e11e.js
index 6d725a081..5fb173854 100644
--- a/public/build/assets/outgame-b0a7e11e.js
+++ b/public/build/assets/outgame-b0a7e11e.js
@@ -203,4 +203,4 @@ ogame.characteristics = {
}
};;
var javascriptAvailable=true;;
-$(document).ready(function(){var a={};jQuery.fn.exists=function(){return jQuery(this).length>0};$("#menu").tabs({event:"click",activate:function(j,k){a=$(j.currentTarget).attr("id");d()},beforeLoad:function(j,k){k.jqXHR.fail(function(){k.panel.html("")})}});a=$("ul#tabs a.current").attr("id");d();$("ul#tabs a").click(function(){$(".formError").hide();a=$(this).attr("id");d()});function d(){switch(a){case"tab1":$("#menu").css("background-position","15px -33px");break;case"tab2":$("#menu").css("background-position","15px -66px");break;case"tab3":$("#menu").css("background-position","15px -99px");break;case"tab4":$("#menu").css("background-position","15px -132px");break;default:break}}function c(){$("#loginBtn").addClass("open").text(JSLoca[1]);$("#login").fadeIn("fast",function(){$("#usernameLogin").focus()});if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("open")}}function i(){$("#loginBtn").removeClass("open").text(JSLoca[0]);$("#login").fadeOut();if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("close")}}$("#loginBtn").click(function(){$(".formError").hide();if($(this).hasClass("open")){i()}else{c()}});$("#pwLost").click(function(){$(this).attr("href","https://"+$("#serverLogin").val()+"/game/reg/mail.php")});$("#emailLost").click(function(){$(this).attr("href","https://"+$("#serverLogin").val()+"/game/index.php?page=standalone&component=retrieveEmail")});$("#resendAct").click(function(){$(".formError").hide();$("#tabs .current").removeClass("current");$("#menu").css("background-position","15px 0");$("#ajaxContent, #passwordLost, #login").hide();$("#resendLink").fadeIn();$("#loginBtn").removeClass("open").text("Login")});$("#trigger").toggle(function(){$("#selected, #language").fadeIn()},function(){$("#selected, #language").fadeOut()});$("#langHead a").click(function(){var j=$(this).attr("title");$("#selected, #displayLang").text(j)});$("#loginForm").validationEngine({validationEventTriggers:"blur",promptPosition:"centerRight",inlineValidation:true});$("#pwLostForm").validationEngine();$("#resendLinkForm").validationEngine();$("#subscribeForm").validationEngine({validationEventTriggers:"keyup blur",promptPosition:"centerRight",inlineValidation:true});$("#regSubmit").click(function(){var j=$("#username");if(j.val()===""){j.val(j.attr("placeholder"))}var k=$("#subscribeForm").validationEngine({returnIsValid:true});if(k==true){submitRegistrationForm();return false}});$("a.overlay").fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",touch:{vertical:false}});$('a.overlay[href*="distinctions"]').fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",baseTpl:'
',touch:{vertical:false}});$("#trailer").click(function(){$(".formError").hide()});function b(l){var j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.$!;:-_#";for(var k=0;k0};$("#menu").tabs({event:"click",activate:function(j,k){a=$(j.currentTarget).attr("id");d()},beforeLoad:function(j,k){k.jqXHR.fail(function(){k.panel.html("")})}});a=$("ul#tabs a.current").attr("id");d();$("ul#tabs a").click(function(){$(".formError").hide();a=$(this).attr("id");d()});function d(){switch(a){case"tab1":$("#menu").css("background-position","15px -33px");break;case"tab2":$("#menu").css("background-position","15px -66px");break;case"tab3":$("#menu").css("background-position","15px -99px");break;case"tab4":$("#menu").css("background-position","15px -132px");break;default:break}}function c(){$("#loginBtn").addClass("open").text(JSLoca[1]);$("#login").fadeIn("fast",function(){$("#usernameLogin").focus()});if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("open")}}function i(){$("#loginBtn").removeClass("open").text(JSLoca[0]);$("#login").fadeOut();if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("close")}}$("#loginBtn").click(function(){$(".formError").hide();if($(this).hasClass("open")){i()}else{c()}});$("#pwLost").click(function(){window.location.href="/forgot-password";return false;});$("#emailLost").click(function(){window.location.href="/forgot-email";return false;});$("#resendAct").click(function(){$(".formError").hide();$("#tabs .current").removeClass("current");$("#menu").css("background-position","15px 0");$("#ajaxContent, #passwordLost, #login").hide();$("#resendLink").fadeIn();$("#loginBtn").removeClass("open").text("Login")});$("#trigger").toggle(function(){$("#selected, #language").fadeIn()},function(){$("#selected, #language").fadeOut()});$("#langHead a").click(function(){var j=$(this).attr("title");$("#selected, #displayLang").text(j)});$("#loginForm").validationEngine({validationEventTriggers:"blur",promptPosition:"centerRight",inlineValidation:true});$("#pwLostForm").validationEngine();$("#resendLinkForm").validationEngine();$("#subscribeForm").validationEngine({validationEventTriggers:"keyup blur",promptPosition:"centerRight",inlineValidation:true});$("#regSubmit").click(function(){var j=$("#username");if(j.val()===""){j.val(j.attr("placeholder"))}var k=$("#subscribeForm").validationEngine({returnIsValid:true});if(k==true){submitRegistrationForm();return false}});$("a.overlay").fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",touch:{vertical:false}});$('a.overlay[href*="distinctions"]').fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",baseTpl:'',touch:{vertical:false}});$("#trailer").click(function(){$(".formError").hide()});function b(l){var j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.$!;:-_#";for(var k=0;k0};$("#menu").tabs({event:"click",activate:function(j,k){a=$(j.currentTarget).attr("id");d()},beforeLoad:function(j,k){k.jqXHR.fail(function(){k.panel.html("")})}});a=$("ul#tabs a.current").attr("id");d();$("ul#tabs a").click(function(){$(".formError").hide();a=$(this).attr("id");d()});function d(){switch(a){case"tab1":$("#menu").css("background-position","15px -33px");break;case"tab2":$("#menu").css("background-position","15px -66px");break;case"tab3":$("#menu").css("background-position","15px -99px");break;case"tab4":$("#menu").css("background-position","15px -132px");break;default:break}}function c(){$("#loginBtn").addClass("open").text(JSLoca[1]);$("#login").fadeIn("fast",function(){$("#usernameLogin").focus()});if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("open")}}function i(){$("#loginBtn").removeClass("open").text(JSLoca[0]);$("#login").fadeOut();if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("close")}}$("#loginBtn").click(function(){$(".formError").hide();if($(this).hasClass("open")){i()}else{c()}});$("#pwLost").click(function(){$(this).attr("href","https://"+$("#serverLogin").val()+"/game/reg/mail.php")});$("#emailLost").click(function(){$(this).attr("href","https://"+$("#serverLogin").val()+"/game/index.php?page=standalone&component=retrieveEmail")});$("#resendAct").click(function(){$(".formError").hide();$("#tabs .current").removeClass("current");$("#menu").css("background-position","15px 0");$("#ajaxContent, #passwordLost, #login").hide();$("#resendLink").fadeIn();$("#loginBtn").removeClass("open").text("Login")});$("#trigger").toggle(function(){$("#selected, #language").fadeIn()},function(){$("#selected, #language").fadeOut()});$("#langHead a").click(function(){var j=$(this).attr("title");$("#selected, #displayLang").text(j)});$("#loginForm").validationEngine({validationEventTriggers:"blur",promptPosition:"centerRight",inlineValidation:true});$("#pwLostForm").validationEngine();$("#resendLinkForm").validationEngine();$("#subscribeForm").validationEngine({validationEventTriggers:"keyup blur",promptPosition:"centerRight",inlineValidation:true});$("#regSubmit").click(function(){var j=$("#username");if(j.val()===""){j.val(j.attr("placeholder"))}var k=$("#subscribeForm").validationEngine({returnIsValid:true});if(k==true){submitRegistrationForm();return false}});$("a.overlay").fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",touch:{vertical:false}});$('a.overlay[href*="distinctions"]').fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",baseTpl:'',touch:{vertical:false}});$("#trailer").click(function(){$(".formError").hide()});function b(l){var j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.$!;:-_#";for(var k=0;k0};$("#menu").tabs({event:"click",activate:function(j,k){a=$(j.currentTarget).attr("id");d()},beforeLoad:function(j,k){k.jqXHR.fail(function(){k.panel.html("")})}});a=$("ul#tabs a.current").attr("id");d();$("ul#tabs a").click(function(){$(".formError").hide();a=$(this).attr("id");d()});function d(){switch(a){case"tab1":$("#menu").css("background-position","15px -33px");break;case"tab2":$("#menu").css("background-position","15px -66px");break;case"tab3":$("#menu").css("background-position","15px -99px");break;case"tab4":$("#menu").css("background-position","15px -132px");break;default:break}}function c(){$("#loginBtn").addClass("open").text(JSLoca[1]);$("#login").fadeIn("fast",function(){$("#usernameLogin").focus()});if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("open")}}function i(){$("#loginBtn").removeClass("open").text(JSLoca[0]);$("#login").fadeOut();if(emailOnlyLogin){$("#transition_email_only_login_dialog").dialog("close")}}$("#loginBtn").click(function(){$(".formError").hide();if($(this).hasClass("open")){i()}else{c()}});$("#pwLost").click(function(){window.location.href="/forgot-password";return false;});$("#emailLost").click(function(){window.location.href="/forgot-email";return false;});$("#resendAct").click(function(){$(".formError").hide();$("#tabs .current").removeClass("current");$("#menu").css("background-position","15px 0");$("#ajaxContent, #passwordLost, #login").hide();$("#resendLink").fadeIn();$("#loginBtn").removeClass("open").text("Login")});$("#trigger").toggle(function(){$("#selected, #language").fadeIn()},function(){$("#selected, #language").fadeOut()});$("#langHead a").click(function(){var j=$(this).attr("title");$("#selected, #displayLang").text(j)});$("#loginForm").validationEngine({validationEventTriggers:"blur",promptPosition:"centerRight",inlineValidation:true});$("#pwLostForm").validationEngine();$("#resendLinkForm").validationEngine();$("#subscribeForm").validationEngine({validationEventTriggers:"keyup blur",promptPosition:"centerRight",inlineValidation:true});$("#regSubmit").click(function(){var j=$("#username");if(j.val()===""){j.val(j.attr("placeholder"))}var k=$("#subscribeForm").validationEngine({returnIsValid:true});if(k==true){submitRegistrationForm();return false}});$("a.overlay").fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",touch:{vertical:false}});$('a.overlay[href*="distinctions"]').fancybox({beforeLoad:function(){$(".formError").hide()},type:"ajax",baseTpl:'',touch:{vertical:false}});$("#trailer").click(function(){$(".formError").hide()});function b(l){var j="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.$!;:-_#";for(var k=0;k 'Use characters only.',
],
+ // Forgot password page
+ 'forgot_password' => [
+ 'title' => 'Forgot your password?',
+ 'description' => 'Enter your email address below and we will send you a link to reset your password.',
+ 'email_label' => 'Email address:',
+ 'submit' => 'Send reset link',
+ 'back_to_login' => '← Back to login',
+ ],
+
+ // Reset password page
+ 'reset_password' => [
+ 'title' => 'Reset your password',
+ 'email_label' => 'Email address:',
+ 'password_label' => 'New password:',
+ 'confirm_label' => 'Confirm new password:',
+ 'submit' => 'Reset password',
+ ],
+
+ // Forgot email page
+ 'forgot_email' => [
+ 'title' => 'Forgot your email address?',
+ 'description' => 'Enter your commander name and we will send a hint to the registered email address.',
+ 'username_label' => 'Commander name:',
+ 'submit' => 'Send hint',
+ 'back_to_login' => '← Back to login',
+ 'sent' => 'If a matching account was found, a hint has been sent to the registered email address.',
+ ],
+
+ // Outgoing email templates
+ 'mail' => [
+ 'reset_password' => [
+ 'subject' => 'Reset your OGameX password',
+ 'heading' => 'Password Reset',
+ 'greeting' => 'Hello :username,',
+ 'body' => 'We received a request to reset the password for your account. Click the button below to choose a new password.',
+ 'cta' => 'Reset Password',
+ 'expiry' => 'This link will expire in 60 minutes.',
+ 'no_action' => 'If you did not request a password reset, no further action is required.',
+ 'url_fallback' => 'If you have trouble clicking the button, copy and paste the URL below into your browser:',
+ ],
+ 'retrieve_email' => [
+ 'subject' => 'Your OGameX email address',
+ 'heading' => 'Email Address Hint',
+ 'greeting' => 'Hello :username,',
+ 'body' => 'You requested a hint for the email address associated with your account:',
+ 'cta' => 'Go to Login',
+ 'no_action' => 'If you did not make this request, you can safely ignore this email.',
+ ],
+ ],
+
// Universe selection characteristics tooltip texts
'universe_characteristics' => [
'fleet_speed' => 'Fleet Speed: the higher the value, the less time you have left to react to an attack.',
diff --git a/resources/lang/it/t_external.php b/resources/lang/it/t_external.php
index 85f6e9a97..ed7f039e5 100644
--- a/resources/lang/it/t_external.php
+++ b/resources/lang/it/t_external.php
@@ -94,6 +94,56 @@
'only_letters' => 'Usa solo lettere.',
],
+ // Pagina password dimenticata
+ 'forgot_password' => [
+ 'title' => 'Hai dimenticato la password?',
+ 'description' => 'Inserisci il tuo indirizzo e-mail e ti invieremo un link per reimpostare la password.',
+ 'email_label' => 'Indirizzo e-mail:',
+ 'submit' => 'Invia link di ripristino',
+ 'back_to_login' => '← Torna al login',
+ ],
+
+ // Pagina reimposta password
+ 'reset_password' => [
+ 'title' => 'Reimposta la password',
+ 'email_label' => 'Indirizzo e-mail:',
+ 'password_label' => 'Nuova password:',
+ 'confirm_label' => 'Conferma nuova password:',
+ 'submit' => 'Reimposta password',
+ ],
+
+ // Pagina e-mail dimenticata
+ 'forgot_email' => [
+ 'title' => 'Hai dimenticato l\'indirizzo e-mail?',
+ 'description' => 'Inserisci il nome del tuo comandante e ti invieremo un suggerimento all\'indirizzo e-mail registrato.',
+ 'username_label' => 'Nome comandante:',
+ 'submit' => 'Invia suggerimento',
+ 'back_to_login' => '← Torna al login',
+ 'sent' => 'Se è stato trovato un account corrispondente, è stato inviato un suggerimento all\'indirizzo e-mail registrato.',
+ ],
+
+ // Template e-mail in uscita
+ 'mail' => [
+ 'reset_password' => [
+ 'subject' => 'Reimposta la tua password OGameX',
+ 'heading' => 'Ripristino Password',
+ 'greeting' => 'Ciao :username,',
+ 'body' => 'Abbiamo ricevuto una richiesta per reimpostare la password del tuo account. Clicca il pulsante qui sotto per scegliere una nuova password.',
+ 'cta' => 'Reimposta Password',
+ 'expiry' => 'Questo link scadrà tra 60 minuti.',
+ 'no_action' => 'Se non hai richiesto il ripristino della password, non è necessaria alcuna ulteriore azione.',
+ 'url_fallback' => 'Se hai problemi a cliccare il pulsante, copia e incolla l\'URL qui sotto nel tuo browser:',
+ ],
+ 'retrieve_email' => [
+ 'subject' => 'Il tuo indirizzo e-mail OGameX',
+ 'heading' => 'Suggerimento Indirizzo E-mail',
+ 'greeting' => 'Ciao :username,',
+ 'body' => 'Hai richiesto un suggerimento per l\'indirizzo e-mail associato al tuo account:',
+ 'cta' => 'Vai al Login',
+ 'no_action' => 'Se non hai effettuato questa richiesta, puoi ignorare questa e-mail.',
+ ],
+ ],
+
// Testi tooltip caratteristiche universo
'universe_characteristics' => [
'fleet_speed' => 'Velocità flotta: maggiore è il valore, meno tempo hai per reagire a un attacco.',
diff --git a/resources/lang/nl/t_external.php b/resources/lang/nl/t_external.php
index 547b39488..2cea83fae 100644
--- a/resources/lang/nl/t_external.php
+++ b/resources/lang/nl/t_external.php
@@ -94,6 +94,56 @@
'only_letters' => 'Gebruik alleen letters.',
],
+ // Pagina wachtwoord vergeten
+ 'forgot_password' => [
+ 'title' => 'Wachtwoord vergeten?',
+ 'description' => 'Voer uw e-mailadres in en wij sturen u een link om uw wachtwoord opnieuw in te stellen.',
+ 'email_label' => 'E-mailadres:',
+ 'submit' => 'Resetlink versturen',
+ 'back_to_login' => '← Terug naar inloggen',
+ ],
+
+ // Pagina wachtwoord opnieuw instellen
+ 'reset_password' => [
+ 'title' => 'Wachtwoord opnieuw instellen',
+ 'email_label' => 'E-mailadres:',
+ 'password_label' => 'Nieuw wachtwoord:',
+ 'confirm_label' => 'Nieuw wachtwoord bevestigen:',
+ 'submit' => 'Wachtwoord opnieuw instellen',
+ ],
+
+ // Pagina e-mailadres vergeten
+ 'forgot_email' => [
+ 'title' => 'E-mailadres vergeten?',
+ 'description' => 'Voer uw commandantsnaam in en wij sturen een hint naar het geregistreerde e-mailadres.',
+ 'username_label' => 'Commandantsnaam:',
+ 'submit' => 'Hint versturen',
+ 'back_to_login' => '← Terug naar inloggen',
+ 'sent' => 'Als er een overeenkomend account is gevonden, is er een hint naar het geregistreerde e-mailadres gestuurd.',
+ ],
+
+ // Uitgaande e-mailsjablonen
+ 'mail' => [
+ 'reset_password' => [
+ 'subject' => 'Stel uw OGameX-wachtwoord opnieuw in',
+ 'heading' => 'Wachtwoord Opnieuw Instellen',
+ 'greeting' => 'Hallo :username,',
+ 'body' => 'We hebben een verzoek ontvangen om het wachtwoord voor uw account opnieuw in te stellen. Klik op de knop hieronder om een nieuw wachtwoord te kiezen.',
+ 'cta' => 'Wachtwoord Opnieuw Instellen',
+ 'expiry' => 'Deze link verloopt over 60 minuten.',
+ 'no_action' => 'Als u geen wachtwoordreset heeft aangevraagd, is geen verdere actie vereist.',
+ 'url_fallback' => 'Als u problemen heeft met het klikken op de knop, kopieer en plak dan de onderstaande URL in uw browser:',
+ ],
+ 'retrieve_email' => [
+ 'subject' => 'Uw OGameX e-mailadres',
+ 'heading' => 'Hint E-mailadres',
+ 'greeting' => 'Hallo :username,',
+ 'body' => 'U heeft een hint aangevraagd voor het e-mailadres dat aan uw account is gekoppeld:',
+ 'cta' => 'Ga naar Inloggen',
+ 'no_action' => 'Als u dit verzoek niet heeft gedaan, kunt u deze e-mail veilig negeren.',
+ ],
+ ],
+
// Tooltip-teksten universum kenmerken
'universe_characteristics' => [
'fleet_speed' => 'Vlootsnelheid: hoe hoger de waarde, hoe minder tijd u heeft om te reageren op een aanval.',
diff --git a/resources/views/outgame/forgot-email.blade.php b/resources/views/outgame/forgot-email.blade.php
new file mode 100644
index 000000000..7a20efca9
--- /dev/null
+++ b/resources/views/outgame/forgot-email.blade.php
@@ -0,0 +1,53 @@
+@extends('outgame.layouts.main')
+
+@section('content')
+
+
+
+@endsection
diff --git a/resources/views/outgame/forgot-password.blade.php b/resources/views/outgame/forgot-password.blade.php
new file mode 100644
index 000000000..d16a366e2
--- /dev/null
+++ b/resources/views/outgame/forgot-password.blade.php
@@ -0,0 +1,53 @@
+@extends('outgame.layouts.main')
+
+@section('content')
+
+
+
+@endsection
diff --git a/resources/views/outgame/layouts/main.blade.php b/resources/views/outgame/layouts/main.blade.php
index 876b7506b..c67aac14c 100644
--- a/resources/views/outgame/layouts/main.blade.php
+++ b/resources/views/outgame/layouts/main.blade.php
@@ -206,9 +206,9 @@ class="browserimg ie">IE 8+
- {{ __('t_external.login.forgot_password') }}
+ {{ __('t_external.login.forgot_password') }}
- {{ __('t_external.login.forgot_email') }}
+ {{ __('t_external.login.forgot_email') }}
{!! __('t_external.login.terms_accept_html') !!}
diff --git a/resources/views/outgame/mail/reset-password.blade.php b/resources/views/outgame/mail/reset-password.blade.php
new file mode 100644
index 000000000..928446c14
--- /dev/null
+++ b/resources/views/outgame/mail/reset-password.blade.php
@@ -0,0 +1,117 @@
+
+
+
+
+
+ {{ __('t_external.mail.reset_password.subject') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OGameX
+
+
+ Conquer the Universe
+
+
+
+
+
+
+
+
+ {{ __('t_external.mail.reset_password.heading') }}
+
+
+ {{ __('t_external.mail.reset_password.greeting', ['username' => $username]) }}
+
+
+ {{ __('t_external.mail.reset_password.body') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⏳ {{ __('t_external.mail.reset_password.expiry') }}
+
+
+ {{ __('t_external.mail.reset_password.no_action') }}
+
+
+
+
+
+
+
+
+ {{ __('t_external.mail.reset_password.url_fallback') }}
+
+
+ {{ $resetUrl }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('t_external.footer.copyright') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/outgame/mail/retrieve-email.blade.php b/resources/views/outgame/mail/retrieve-email.blade.php
new file mode 100644
index 000000000..3510f80b4
--- /dev/null
+++ b/resources/views/outgame/mail/retrieve-email.blade.php
@@ -0,0 +1,116 @@
+
+
+
+
+
+ {{ __('t_external.mail.retrieve_email.subject') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OGameX
+
+
+ Conquer the Universe
+
+
+
+
+
+
+
+
+ {{ __('t_external.mail.retrieve_email.heading') }}
+
+
+ {{ __('t_external.mail.retrieve_email.greeting', ['username' => $username]) }}
+
+
+ {{ __('t_external.mail.retrieve_email.body') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $maskedEmail }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('t_external.mail.retrieve_email.no_action') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('t_external.footer.copyright') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/outgame/reset-password.blade.php b/resources/views/outgame/reset-password.blade.php
new file mode 100644
index 000000000..1153c6682
--- /dev/null
+++ b/resources/views/outgame/reset-password.blade.php
@@ -0,0 +1,72 @@
+@extends('outgame.layouts.main')
+
+@section('content')
+
+
+
+@endsection
diff --git a/routes/web.php b/routes/web.php
index 65dc6074b..e62382cb6 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -16,6 +16,7 @@
use OGame\Http\Controllers\FacilitiesController;
use OGame\Http\Controllers\FleetController;
use OGame\Http\Controllers\FleetEventsController;
+use OGame\Http\Controllers\ForgotEmailController;
use OGame\Http\Controllers\GalaxyController;
use OGame\Http\Controllers\HighscoreController;
use OGame\Http\Controllers\JumpGateController;
@@ -56,6 +57,10 @@
// Language switcher — accessible to both guests and authenticated users.
Route::get('/lang/{lang}', [LanguageController::class, 'switchLang'])->name('language.switch');
+// Forgot email lookup (guest only).
+Route::get('/forgot-email', [ForgotEmailController::class, 'show'])->name('password.email-lookup');
+Route::post('/forgot-email', [ForgotEmailController::class, 'send'])->middleware('throttle:forgot-email');
+
// Public AJAX endpoints (no auth required).
Route::get('/ajax/main/rules', [RulesController::class, 'ajaxRules'])->name('rules.ajax');
Route::get('/ajax/main/legal', [RulesController::class, 'ajaxLegal'])->name('legal.ajax');
diff --git a/tests/Feature/ForgotEmailTest.php b/tests/Feature/ForgotEmailTest.php
new file mode 100644
index 000000000..d25ad47f3
--- /dev/null
+++ b/tests/Feature/ForgotEmailTest.php
@@ -0,0 +1,105 @@
+post('/logout');
+
+ $response = $this->get('/forgot-email');
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Test that submitting an existing username sends the retrieval mail.
+ */
+ public function testEmailSentForExistingUsername(): void
+ {
+ $this->post('/logout');
+ Mail::fake();
+
+ $user = User::find($this->currentUserId);
+
+ $response = $this->post('/forgot-email', [
+ 'username' => $user->username,
+ ]);
+
+ $response->assertRedirect(route('password.email-lookup'));
+ $response->assertSessionHas('status');
+
+ Mail::assertSent(RetrieveEmailMail::class, function (RetrieveEmailMail $mail) use ($user) {
+ return $mail->hasTo($user->email) && $mail->username === $user->username;
+ });
+ }
+
+ /**
+ * Test that submitting a non-existent username does NOT send a mail
+ * but still returns the same success redirect (no username enumeration).
+ */
+ public function testEmailNotSentForUnknownUsername(): void
+ {
+ $this->post('/logout');
+ Mail::fake();
+
+ $response = $this->post('/forgot-email', [
+ 'username' => 'nonexistent-user-' . uniqid(),
+ ]);
+
+ $response->assertRedirect(route('password.email-lookup'));
+ $response->assertSessionHas('status');
+
+ Mail::assertNotSent(RetrieveEmailMail::class);
+ }
+
+ /**
+ * Test that the masked email is correct (first 2 chars visible, rest masked).
+ */
+ public function testMaskedEmailInMailContent(): void
+ {
+ $this->post('/logout');
+ Mail::fake();
+
+ $user = User::find($this->currentUserId);
+
+ $this->post('/forgot-email', [
+ 'username' => $user->username,
+ ]);
+
+ Mail::assertSent(RetrieveEmailMail::class, function (RetrieveEmailMail $mail) use ($user) {
+ // The masked email should start with the first 2 chars of the local part.
+ [$local, $domain] = explode('@', $user->email, 2);
+ $expectedPrefix = mb_substr($local, 0, min(2, mb_strlen($local)));
+
+ return str_starts_with($mail->maskedEmail, $expectedPrefix)
+ && str_contains($mail->maskedEmail, '@' . $domain)
+ && str_contains($mail->maskedEmail, '***');
+ });
+ }
+
+ /**
+ * Test that submitting without a username returns a validation error.
+ */
+ public function testValidationRequiresUsername(): void
+ {
+ $this->post('/logout');
+
+ $response = $this->post('/forgot-email', [
+ 'username' => '',
+ ]);
+
+ $response->assertSessionHasErrors('username');
+ }
+}
diff --git a/tests/Feature/ForgotPasswordTest.php b/tests/Feature/ForgotPasswordTest.php
new file mode 100644
index 000000000..54c9bd013
--- /dev/null
+++ b/tests/Feature/ForgotPasswordTest.php
@@ -0,0 +1,120 @@
+post('/logout');
+
+ $response = $this->get('/forgot-password');
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Test that requesting a reset link for an existing email sends a mail.
+ */
+ public function testResetLinkSentForExistingEmail(): void
+ {
+ $this->post('/logout');
+ Mail::fake();
+
+ $user = User::find($this->currentUserId);
+
+ $response = $this->post('/forgot-password', [
+ 'email' => $user->email,
+ ]);
+
+ $response->assertRedirect();
+
+ Mail::assertSent(ResetPasswordMail::class, function (ResetPasswordMail $mail) use ($user) {
+ return $mail->hasTo($user->email) && $mail->username === $user->username;
+ });
+ }
+
+ /**
+ * Test that requesting a reset link for a non-existent email does not send a mail
+ * but still redirects (no user enumeration).
+ */
+ public function testResetLinkNotSentForUnknownEmail(): void
+ {
+ $this->post('/logout');
+ Mail::fake();
+
+ $response = $this->post('/forgot-password', [
+ 'email' => 'nonexistent-user-' . uniqid() . '@example.com',
+ ]);
+
+ $response->assertRedirect();
+
+ Mail::assertNotSent(ResetPasswordMail::class);
+ }
+
+ /**
+ * Test that the reset-password form renders with a valid token.
+ */
+ public function testResetPasswordPageLoadsWithToken(): void
+ {
+ $this->post('/logout');
+
+ $user = User::find($this->currentUserId);
+ $token = Password::createToken($user);
+
+ $response = $this->get('/reset-password/' . $token . '?email=' . urlencode($user->email));
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Test that submitting a valid reset completes successfully.
+ */
+ public function testPasswordCanBeResetWithValidToken(): void
+ {
+ $this->post('/logout');
+
+ $user = User::find($this->currentUserId);
+ $token = Password::createToken($user);
+
+ $response = $this->post('/reset-password', [
+ 'token' => $token,
+ 'email' => $user->email,
+ 'password' => 'new-password-123',
+ 'password_confirmation' => 'new-password-123',
+ ]);
+
+ $response->assertRedirect('/login');
+ }
+
+ /**
+ * Test that an invalid token is rejected.
+ */
+ public function testPasswordResetFailsWithInvalidToken(): void
+ {
+ $this->post('/logout');
+
+ $user = User::find($this->currentUserId);
+
+ $response = $this->post('/reset-password', [
+ 'token' => 'invalid-token-value',
+ 'email' => $user->email,
+ 'password' => 'new-password-123',
+ 'password_confirmation' => 'new-password-123',
+ ]);
+
+ // Should redirect back with errors (invalid token).
+ $response->assertRedirect();
+ $response->assertSessionHasErrors();
+ }
+}