feat(email): implement email change request and verification process
- Added functionality for users to request an email change, including generating a verification code and setting an expiration time. - Implemented methods in the User model to handle email change requests, code validation, and confirmation. - Created a new job to update the user's email in Stripe after confirmation. - Introduced rate limiting for email change requests and verification attempts to prevent abuse. - Added a new notification for email change verification. - Updated the profile component to manage email change requests and verification UI.
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\TeamInvitation;
|
use App\Models\TeamInvitation;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
@@ -30,6 +31,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->cleanupInvitationLink();
|
$this->cleanupInvitationLink();
|
||||||
|
$this->cleanupExpiredEmailChangeRequests();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
|
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -42,4 +44,15 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
|
|||||||
$item->isValid();
|
$item->isValid();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function cleanupExpiredEmailChangeRequests()
|
||||||
|
{
|
||||||
|
User::whereNotNull('email_change_code_expires_at')
|
||||||
|
->where('email_change_code_expires_at', '<', now())
|
||||||
|
->update([
|
||||||
|
'pending_email' => null,
|
||||||
|
'email_change_code' => null,
|
||||||
|
'email_change_code_expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
133
app/Jobs/UpdateStripeCustomerEmailJob.php
Normal file
133
app/Jobs/UpdateStripeCustomerEmailJob.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Team;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Stripe\Stripe;
|
||||||
|
|
||||||
|
class UpdateStripeCustomerEmailJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $tries = 3;
|
||||||
|
|
||||||
|
public $backoff = [10, 30, 60];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Team $team,
|
||||||
|
private int $userId,
|
||||||
|
private string $newEmail,
|
||||||
|
private string $oldEmail
|
||||||
|
) {
|
||||||
|
$this->onQueue('high');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (! isCloud() || ! $this->team->subscription) {
|
||||||
|
Log::info('Skipping Stripe email update - not cloud or no subscription', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user changing email is a team owner
|
||||||
|
$isOwner = $this->team->members()
|
||||||
|
->wherePivot('role', 'owner')
|
||||||
|
->where('users.id', $this->userId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $isOwner) {
|
||||||
|
Log::info('Skipping Stripe email update - user is not team owner', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current Stripe customer email to verify it matches the user's old email
|
||||||
|
$stripe_customer_id = data_get($this->team, 'subscription.stripe_customer_id');
|
||||||
|
if (! $stripe_customer_id) {
|
||||||
|
Log::info('Skipping Stripe email update - no Stripe customer ID', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stripe::setApiKey(config('subscription.stripe_api_key'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stripeCustomer = \Stripe\Customer::retrieve($stripe_customer_id);
|
||||||
|
$currentStripeEmail = $stripeCustomer->email;
|
||||||
|
|
||||||
|
// Only update if the current Stripe email matches the user's old email
|
||||||
|
if (strtolower($currentStripeEmail) !== strtolower($this->oldEmail)) {
|
||||||
|
Log::info('Skipping Stripe email update - Stripe customer email does not match user old email', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'stripe_email' => $currentStripeEmail,
|
||||||
|
'user_old_email' => $this->oldEmail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Stripe customer email
|
||||||
|
\Stripe\Customer::update($stripe_customer_id, ['email' => $this->newEmail]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to retrieve or update Stripe customer', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'stripe_customer_id' => $stripe_customer_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Successfully updated Stripe customer email', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'old_email' => $this->oldEmail,
|
||||||
|
'new_email' => $this->newEmail,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to update Stripe customer email', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'old_email' => $this->oldEmail,
|
||||||
|
'new_email' => $this->newEmail,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'attempt' => $this->attempts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Re-throw to trigger retry
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::error('Permanently failed to update Stripe customer email after all retries', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'old_email' => $this->oldEmail,
|
||||||
|
'new_email' => $this->newEmail,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
@@ -4,6 +4,7 @@ namespace App\Livewire\Profile;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -23,11 +24,25 @@ class Index extends Component
|
|||||||
#[Validate('required')]
|
#[Validate('required')]
|
||||||
public string $name;
|
public string $name;
|
||||||
|
|
||||||
|
public string $new_email = '';
|
||||||
|
|
||||||
|
public string $email_verification_code = '';
|
||||||
|
|
||||||
|
public bool $show_email_change = false;
|
||||||
|
|
||||||
|
public bool $show_verification = false;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->userId = Auth::id();
|
$this->userId = Auth::id();
|
||||||
$this->name = Auth::user()->name;
|
$this->name = Auth::user()->name;
|
||||||
$this->email = Auth::user()->email;
|
$this->email = Auth::user()->email;
|
||||||
|
|
||||||
|
// Check if there's a pending email change
|
||||||
|
if (Auth::user()->hasEmailChangeRequest()) {
|
||||||
|
$this->new_email = Auth::user()->pending_email;
|
||||||
|
$this->show_verification = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
@@ -46,6 +61,180 @@ class Index extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function requestEmailChange()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// For self-hosted, check if email is enabled
|
||||||
|
if (! isCloud()) {
|
||||||
|
$settings = instanceSettings();
|
||||||
|
if (! $settings->smtp_enabled && ! $settings->resend_enabled) {
|
||||||
|
$this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'new_email' => ['required', 'email', 'unique:users,email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Skip rate limiting in development mode
|
||||||
|
if (! isDev()) {
|
||||||
|
// Rate limit by current user's email (1 request per 2 minutes)
|
||||||
|
$userEmailKey = 'email-change:user:'.Auth::id();
|
||||||
|
if (! RateLimiter::attempt($userEmailKey, 1, function () {}, 120)) {
|
||||||
|
$seconds = RateLimiter::availableIn($userEmailKey);
|
||||||
|
$this->dispatch('error', 'Too many requests. Please wait '.$seconds.' seconds before trying again.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit by new email address (3 requests per hour per email)
|
||||||
|
$newEmailKey = 'email-change:email:'.md5(strtolower($this->new_email));
|
||||||
|
if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) {
|
||||||
|
$this->dispatch('error', 'This email address has received too many verification requests. Please try again later.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional rate limit by IP address (5 requests per hour)
|
||||||
|
$ipKey = 'email-change:ip:'.request()->ip();
|
||||||
|
if (! RateLimiter::attempt($ipKey, 5, function () {}, 3600)) {
|
||||||
|
$this->dispatch('error', 'Too many requests from your IP address. Please try again later.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::user()->requestEmailChange($this->new_email);
|
||||||
|
|
||||||
|
$this->show_email_change = false;
|
||||||
|
$this->show_verification = true;
|
||||||
|
|
||||||
|
$this->dispatch('success', 'Verification code sent to '.$this->new_email);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyEmailChange()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->validate([
|
||||||
|
'email_verification_code' => ['required', 'string', 'size:6'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Skip rate limiting in development mode
|
||||||
|
if (! isDev()) {
|
||||||
|
// Rate limit verification attempts (5 attempts per 10 minutes)
|
||||||
|
$verifyKey = 'email-verify:user:'.Auth::id();
|
||||||
|
if (! RateLimiter::attempt($verifyKey, 5, function () {}, 600)) {
|
||||||
|
$seconds = RateLimiter::availableIn($verifyKey);
|
||||||
|
$minutes = ceil($seconds / 60);
|
||||||
|
$this->dispatch('error', 'Too many verification attempts. Please wait '.$minutes.' minutes before trying again.');
|
||||||
|
|
||||||
|
// If too many failed attempts, clear the email change request for security
|
||||||
|
if (RateLimiter::attempts($verifyKey) >= 10) {
|
||||||
|
Auth::user()->clearEmailChangeRequest();
|
||||||
|
$this->new_email = '';
|
||||||
|
$this->email_verification_code = '';
|
||||||
|
$this->show_verification = false;
|
||||||
|
$this->dispatch('error', 'Email change request cancelled due to too many failed attempts. Please start over.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Auth::user()->isEmailChangeCodeValid($this->email_verification_code)) {
|
||||||
|
$this->dispatch('error', 'Invalid or expired verification code.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Auth::user()->confirmEmailChange($this->email_verification_code)) {
|
||||||
|
// Clear rate limiters on successful verification (only in production)
|
||||||
|
if (! isDev()) {
|
||||||
|
$verifyKey = 'email-verify:user:'.Auth::id();
|
||||||
|
RateLimiter::clear($verifyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->email = Auth::user()->email;
|
||||||
|
$this->new_email = '';
|
||||||
|
$this->email_verification_code = '';
|
||||||
|
$this->show_verification = false;
|
||||||
|
|
||||||
|
$this->dispatch('success', 'Email address updated successfully.');
|
||||||
|
} else {
|
||||||
|
$this->dispatch('error', 'Failed to update email address.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resendVerificationCode()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Check if there's a pending request
|
||||||
|
if (! Auth::user()->hasEmailChangeRequest()) {
|
||||||
|
$this->dispatch('error', 'No pending email change request.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough time has passed (at least half of the expiry time)
|
||||||
|
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
|
||||||
|
$halfExpiryMinutes = $expiryMinutes / 2;
|
||||||
|
$codeExpiry = Auth::user()->email_change_code_expires_at;
|
||||||
|
$timeSinceCreated = $codeExpiry->subMinutes($expiryMinutes)->diffInMinutes(now());
|
||||||
|
|
||||||
|
if ($timeSinceCreated < $halfExpiryMinutes) {
|
||||||
|
$minutesToWait = ceil($halfExpiryMinutes - $timeSinceCreated);
|
||||||
|
$this->dispatch('error', 'Please wait '.$minutesToWait.' more minutes before requesting a new code.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingEmail = Auth::user()->pending_email;
|
||||||
|
|
||||||
|
// Skip rate limiting in development mode
|
||||||
|
if (! isDev()) {
|
||||||
|
// Rate limit by email address
|
||||||
|
$newEmailKey = 'email-change:email:'.md5(strtolower($pendingEmail));
|
||||||
|
if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) {
|
||||||
|
$this->dispatch('error', 'This email address has received too many verification requests. Please try again later.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and send new code
|
||||||
|
Auth::user()->requestEmailChange($pendingEmail);
|
||||||
|
|
||||||
|
$this->dispatch('success', 'New verification code sent to '.$pendingEmail);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelEmailChange()
|
||||||
|
{
|
||||||
|
Auth::user()->clearEmailChangeRequest();
|
||||||
|
$this->new_email = '';
|
||||||
|
$this->email_verification_code = '';
|
||||||
|
$this->show_email_change = false;
|
||||||
|
$this->show_verification = false;
|
||||||
|
|
||||||
|
$this->dispatch('success', 'Email change request cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showEmailChangeForm()
|
||||||
|
{
|
||||||
|
$this->show_email_change = true;
|
||||||
|
$this->new_email = '';
|
||||||
|
}
|
||||||
|
|
||||||
public function resetPassword()
|
public function resetPassword()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
@@ -53,6 +53,7 @@ class User extends Authenticatable implements SendsEmail
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'force_password_reset' => 'boolean',
|
'force_password_reset' => 'boolean',
|
||||||
'show_boarding' => 'boolean',
|
'show_boarding' => 'boolean',
|
||||||
|
'email_change_code_expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
@@ -320,4 +321,77 @@ class User extends Authenticatable implements SendsEmail
|
|||||||
|
|
||||||
return data_get($user, 'pivot.role');
|
return data_get($user, 'pivot.role');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function requestEmailChange(string $newEmail): void
|
||||||
|
{
|
||||||
|
// Generate 6-digit code
|
||||||
|
$code = sprintf('%06d', mt_rand(0, 999999));
|
||||||
|
|
||||||
|
// Set expiration using config value
|
||||||
|
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
|
||||||
|
$expiresAt = Carbon::now()->addMinutes($expiryMinutes);
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'pending_email' => $newEmail,
|
||||||
|
'email_change_code' => $code,
|
||||||
|
'email_change_code_expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send verification email to new address
|
||||||
|
$this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmailChangeCodeValid(string $code): bool
|
||||||
|
{
|
||||||
|
return $this->email_change_code === $code
|
||||||
|
&& $this->email_change_code_expires_at
|
||||||
|
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmEmailChange(string $code): bool
|
||||||
|
{
|
||||||
|
if (! $this->isEmailChangeCodeValid($code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldEmail = $this->email;
|
||||||
|
$newEmail = $this->pending_email;
|
||||||
|
|
||||||
|
// Update email and clear change request fields
|
||||||
|
$this->update([
|
||||||
|
'email' => $newEmail,
|
||||||
|
'pending_email' => null,
|
||||||
|
'email_change_code' => null,
|
||||||
|
'email_change_code_expires_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// For cloud users, dispatch job to update Stripe customer email asynchronously
|
||||||
|
if (isCloud() && $this->currentTeam()->subscription) {
|
||||||
|
dispatch(new \App\Jobs\UpdateStripeCustomerEmailJob(
|
||||||
|
$this->currentTeam(),
|
||||||
|
$this->id,
|
||||||
|
$newEmail,
|
||||||
|
$oldEmail
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearEmailChangeRequest(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'pending_email' => null,
|
||||||
|
'email_change_code' => null,
|
||||||
|
'email_change_code_expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasEmailChangeRequest(): bool
|
||||||
|
{
|
||||||
|
return ! is_null($this->pending_email)
|
||||||
|
&& ! is_null($this->email_change_code)
|
||||||
|
&& $this->email_change_code_expires_at
|
||||||
|
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -16,7 +16,12 @@ class TransactionalEmailChannel
|
|||||||
if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) {
|
if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$email = $notifiable->email;
|
|
||||||
|
// Check if notification has a custom recipient (for email changes)
|
||||||
|
$email = property_exists($notification, 'newEmail') && $notification->newEmail
|
||||||
|
? $notification->newEmail
|
||||||
|
: $notifiable->email;
|
||||||
|
|
||||||
if (! $email) {
|
if (! $email) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications\TransactionalEmails;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\Channels\TransactionalEmailChannel;
|
||||||
|
use App\Notifications\CustomEmailNotification;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class EmailChangeVerification extends CustomEmailNotification
|
||||||
|
{
|
||||||
|
public function via(): array
|
||||||
|
{
|
||||||
|
return [TransactionalEmailChannel::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public string $verificationCode,
|
||||||
|
public string $newEmail,
|
||||||
|
public Carbon $expiresAt,
|
||||||
|
public bool $isTransactionalEmail = true
|
||||||
|
) {
|
||||||
|
$this->onQueue('high');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(): MailMessage
|
||||||
|
{
|
||||||
|
// Use the configured expiry minutes value
|
||||||
|
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
|
||||||
|
|
||||||
|
$mail = new MailMessage;
|
||||||
|
$mail->subject('Coolify: Verify Your New Email Address');
|
||||||
|
$mail->view('emails.email-change-verification', [
|
||||||
|
'newEmail' => $this->newEmail,
|
||||||
|
'verificationCode' => $this->verificationCode,
|
||||||
|
'expiryMinutes' => $expiryMinutes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $mail;
|
||||||
|
}
|
||||||
|
}
|
@@ -89,3 +89,22 @@ function allowedPathsForInvalidAccounts()
|
|||||||
'livewire/update',
|
'livewire/update',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateStripeCustomerEmail(Team $team, string $newEmail): void
|
||||||
|
{
|
||||||
|
if (! isStripe()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripe_customer_id = data_get($team, 'subscription.stripe_customer_id');
|
||||||
|
if (! $stripe_customer_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stripe::setApiKey(config('subscription.stripe_api_key'));
|
||||||
|
|
||||||
|
\Stripe\Customer::update(
|
||||||
|
$stripe_customer_id,
|
||||||
|
['email' => $newEmail]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -70,6 +70,10 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'email_change' => [
|
||||||
|
'verification_code_expiry_minutes' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
'sentry' => [
|
'sentry' => [
|
||||||
'sentry_dsn' => env('SENTRY_DSN'),
|
'sentry_dsn' => env('SENTRY_DSN'),
|
||||||
],
|
],
|
||||||
|
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('pending_email')->nullable()->after('email');
|
||||||
|
$table->string('email_change_code', 6)->nullable()->after('pending_email');
|
||||||
|
$table->timestamp('email_change_code_expires_at')->nullable()->after('email_change_code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['pending_email', 'email_change_code', 'email_change_code_expires_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@@ -32,7 +32,8 @@
|
|||||||
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled"
|
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled"
|
||||||
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
|
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
|
||||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||||
aria-placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif>
|
aria-placeholder="{{ $attributes->get('placeholder') }}"
|
||||||
|
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@@ -45,7 +46,8 @@
|
|||||||
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
|
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
|
||||||
maxlength="{{ $attributes->get('maxlength') }}"
|
maxlength="{{ $attributes->get('maxlength') }}"
|
||||||
@if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}"
|
@if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}"
|
||||||
placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif>
|
placeholder="{{ $attributes->get('placeholder') }}"
|
||||||
|
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||||
@endif
|
@endif
|
||||||
@if (!$label && $helper)
|
@if (!$label && $helper)
|
||||||
<x-helper :helper="$helper" />
|
<x-helper :helper="$helper" />
|
||||||
|
11
resources/views/emails/email-change-verification.blade.php
Normal file
11
resources/views/emails/email-change-verification.blade.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<x-emails.layout>
|
||||||
|
You have requested to change your email address to: {{ $newEmail }}
|
||||||
|
|
||||||
|
Please use the following verification code to confirm this change:
|
||||||
|
|
||||||
|
Verification Code: {{ $verificationCode }}
|
||||||
|
|
||||||
|
This code is valid for {{ $expiryMinutes }} minutes.
|
||||||
|
|
||||||
|
If you did not request this change, please ignore this email and your email address will remain unchanged.
|
||||||
|
</x-emails.layout>
|
@@ -9,11 +9,52 @@
|
|||||||
<h2>General</h2>
|
<h2>General</h2>
|
||||||
<x-forms.button type="submit" label="Save">Save</x-forms.button>
|
<x-forms.button type="submit" label="Save">Save</x-forms.button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 lg:flex-row">
|
<div class="flex flex-col gap-2 lg:flex-row items-end">
|
||||||
<x-forms.input id="name" label="Name" required />
|
<x-forms.input id="name" label="Name" required />
|
||||||
<x-forms.input id="email" label="Email" readonly />
|
<x-forms.input id="email" label="Email" readonly />
|
||||||
|
@if (!$show_email_change && !$show_verification)
|
||||||
|
<x-forms.button wire:click="showEmailChangeForm" type="button">Change Email</x-forms.button>
|
||||||
|
@else
|
||||||
|
<x-forms.button wire:click="showEmailChangeForm" type="button" disabled>Change Email</x-forms.button>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="flex flex-col pt-4">
|
||||||
|
@if ($show_email_change)
|
||||||
|
<form wire:submit='requestEmailChange'>
|
||||||
|
<div class="flex gap-2 items-end">
|
||||||
|
<x-forms.input id="new_email" label="New Email Address" required type="email" />
|
||||||
|
<x-forms.button type="submit">Send Verification Code</x-forms.button>
|
||||||
|
<x-forms.button wire:click="$set('show_email_change', false)" type="button"
|
||||||
|
isError>Cancel</x-forms.button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-bold dark:text-warning pt-2">A verification code will be sent to your
|
||||||
|
new email
|
||||||
|
address.</div>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($show_verification)
|
||||||
|
<form wire:submit='verifyEmailChange'>
|
||||||
|
<div class="flex gap-2 items-end">
|
||||||
|
<x-forms.input id="email_verification_code" label="Verification Code (6 digits)" required
|
||||||
|
maxlength="6" />
|
||||||
|
<x-forms.button type="submit">Verify & Update Email</x-forms.button>
|
||||||
|
<x-forms.button wire:click="resendVerificationCode" type="button" isWarning>Resend
|
||||||
|
Code</x-forms.button>
|
||||||
|
<x-forms.button wire:click="cancelEmailChange" type="button" isError>Cancel</x-forms.button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-bold dark:text-warning pt-2">
|
||||||
|
Verification code sent to {{ $new_email ?? auth()->user()->pending_email }}.
|
||||||
|
The code is valid for {{ config('constants.email_change.verification_code_expiry_minutes', 10) }}
|
||||||
|
minutes.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
<form wire:submit='resetPassword' class="flex flex-col pt-4">
|
<form wire:submit='resetPassword' class="flex flex-col pt-4">
|
||||||
<div class="flex items-center gap-2 pb-2">
|
<div class="flex items-center gap-2 pb-2">
|
||||||
<h2>Change Password</h2>
|
<h2>Change Password</h2>
|
||||||
|
Reference in New Issue
Block a user