Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Http/Controllers/Api/PluginAccessController.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public function index(Request $request): JsonResponse
return response()->json([
'success' => true,
'user' => [
'id' => $user->id,
'email' => $user->email,
],
'plugins' => $accessiblePlugins,
Expand Down
79 changes: 79 additions & 0 deletions app/Http/Controllers/Auth/EmailChangeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Jobs\SyncStripeCustomerDetailsJob;
use App\Models\EmailChange;
use App\Models\TeamUser;
use App\Models\User;
use App\Notifications\EmailChangeCompleted;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;

class EmailChangeController extends Controller
{
public function confirm(Request $request, EmailChange $emailChange): RedirectResponse
{
$user = $request->user();

abort_if($emailChange->user_id !== $user->id, 403);

if ($emailChange->isConfirmed()) {
return to_route('customer.settings', ['tab' => 'account'])
->with('email-changed', 'Your email address has already been updated.');
}

abort_if($emailChange->isExpired(), 403);

if (User::query()->where('email', $emailChange->new_email)->exists()) {
return to_route('customer.settings', ['tab' => 'account'])
->with('email-change-failed', 'That email address is no longer available. Please request a new change.');
}

$oldEmail = $user->email;

DB::transaction(function () use ($user, $emailChange, $oldEmail): void {
$user->update([
'email' => $emailChange->new_email,
'email_verified_at' => now(),
]);

$emailChange->update(['confirmed_at' => now()]);

$this->updateTeamMemberships($user, $oldEmail, $emailChange->new_email);
});

SyncStripeCustomerDetailsJob::dispatch($user);

Notification::route('mail', $oldEmail)->notify(new EmailChangeCompleted($emailChange));

return to_route('customer.settings', ['tab' => 'account'])
->with('email-changed', 'Your email address has been updated. Remember to update your Composer credentials (auth.json and any CI secrets) to use the new address, or plugin installs will fail.');
}

/**
* Keep team membership records in sync with the user's new email,
* skipping any team that already has a record for the new address.
*/
protected function updateTeamMemberships(User $user, string $oldEmail, string $newEmail): void
{
$memberships = TeamUser::query()
->where('user_id', $user->id)
->where('email', $oldEmail)
->get();

foreach ($memberships as $membership) {
$emailTaken = TeamUser::query()
->where('team_id', $membership->team_id)
->where('email', $newEmail)
->exists();

if (! $emailTaken) {
$membership->update(['email' => $newEmail]);
}
}
}
}
28 changes: 28 additions & 0 deletions app/Jobs/SyncStripeCustomerDetailsJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Jobs;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SyncStripeCustomerDetailsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function __construct(
public User $user
) {}

public function handle(): void
{
if (! $this->user->hasStripeId()) {
return;
}

$this->user->syncStripeCustomerDetails();
}
}
104 changes: 104 additions & 0 deletions app/Livewire/Customer/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@

namespace App\Livewire\Customer;

use App\Models\EmailChange;
use App\Notifications\EmailChangeRequested;
use App\Notifications\VerifyNewEmailAddress;
use Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
Expand All @@ -18,6 +25,10 @@ class Settings extends Component

public string $name = '';

public string $newEmail = '';

public string $emailChangePassword = '';

public string $currentPassword = '';

public string $newPassword = '';
Expand Down Expand Up @@ -47,6 +58,99 @@ public function updatedReceivesNewPluginNotifications(bool $value): void
auth()->user()->update(['receives_new_plugin_notifications' => $value]);
}

#[Computed]
public function pendingEmailChange(): ?EmailChange
{
return auth()->user()->emailChanges()->pending()->latest()->first();
}

public function resetEmailChangeForm(): void
{
$this->reset('newEmail', 'emailChangePassword');
$this->resetValidation(['newEmail', 'emailChangePassword']);
}

public function requestEmailChange(): void
{
$this->validate([
'newEmail' => [
'required',
'string',
'email',
'max:255',
function (string $attribute, mixed $value, \Closure $fail): void {
if (strcasecmp($value, auth()->user()->email) === 0) {
$fail('This is already your email address.');
}
},
'unique:users,email',
],
'emailChangePassword' => ['required', 'current_password'],
]);

$user = auth()->user();
$rateLimiterKey = 'email-change:'.$user->id;

if (RateLimiter::tooManyAttempts($rateLimiterKey, 3)) {
$this->addError('newEmail', 'Too many email change requests. Please try again later.');

return;
}

RateLimiter::hit($rateLimiterKey, 600);

$user->emailChanges()->whereNull('confirmed_at')->delete();

$emailChange = $user->emailChanges()->create([
'old_email' => $user->email,
'new_email' => $this->newEmail,
'ip_address' => request()->ip(),
'expires_at' => now()->addHour(),
]);

Notification::route('mail', $emailChange->new_email)->notify(new VerifyNewEmailAddress($emailChange));
$user->notify(new EmailChangeRequested($emailChange));

$this->reset('newEmail', 'emailChangePassword');
unset($this->pendingEmailChange);

Flux::modal('change-email')->close();

session()->flash('email-change-requested', "We've sent a confirmation link to {$emailChange->new_email}. Your email will change once you click it.");
}

public function resendEmailChange(): void
{
$emailChange = $this->pendingEmailChange;

if (! $emailChange) {
return;
}

$rateLimiterKey = 'email-change:'.auth()->id();

if (RateLimiter::tooManyAttempts($rateLimiterKey, 3)) {
$this->addError('newEmail', 'Too many email change requests. Please try again later.');

return;
}

RateLimiter::hit($rateLimiterKey, 600);

Notification::route('mail', $emailChange->new_email)->notify(new VerifyNewEmailAddress($emailChange));

session()->flash('email-change-requested', "We've re-sent the confirmation link to {$emailChange->new_email}.");
}

public function cancelEmailChange(): void
{
auth()->user()->emailChanges()->whereNull('confirmed_at')->delete();

unset($this->pendingEmailChange);

session()->flash('email-change-cancelled', 'The email change has been cancelled.');
}

public function updateName(): void
{
$this->validate([
Expand Down
54 changes: 54 additions & 0 deletions app/Models/EmailChange.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace App\Models;

use Database\Factories\EmailChangeFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\URL;

class EmailChange extends Model
{
/** @use HasFactory<EmailChangeFactory> */
use HasFactory;

protected $guarded = [];

/**
* @return BelongsTo<User>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

public function scopePending(Builder $query): Builder
{
return $query->whereNull('confirmed_at')->where('expires_at', '>', now());
}

public function isConfirmed(): bool
{
return $this->confirmed_at !== null;
}

public function isExpired(): bool
{
return $this->expires_at->isPast();
}

public function confirmationUrl(): string
{
return URL::temporarySignedRoute('email-change.confirm', $this->expires_at, ['emailChange' => $this->id]);
}

protected function casts(): array
{
return [
'expires_at' => 'datetime',
'confirmed_at' => 'datetime',
];
}
}
8 changes: 8 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ public function licenses(): HasMany
return $this->hasMany(License::class);
}

/**
* @return HasMany<EmailChange>
*/
public function emailChanges(): HasMany
{
return $this->hasMany(EmailChange::class);
}

/**
* @return HasMany<WallOfLoveSubmission>
*/
Expand Down
54 changes: 54 additions & 0 deletions app/Notifications/EmailChangeCompleted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace App\Notifications;

use App\Models\EmailChange;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class EmailChangeCompleted extends Notification implements ShouldQueue
{
use Queueable;

public function __construct(
public EmailChange $emailChange
) {}

/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}

/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Your Account Email Has Been Changed')
->greeting('Hello!')
->line("The email address on your NativePHP account has been changed from {$this->emailChange->old_email} to {$this->emailChange->new_email}.")
->line('From now on, use the new address to log in.')
->line('If this was not you, contact our support team immediately.');
}

/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'email_change_id' => $this->emailChange->id,
'new_email' => $this->emailChange->new_email,
];
}
}
Loading
Loading