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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class AppServiceProvider extends ServiceProvider
// Customize user agent generation
DeviceCreator::userAgentUsing(fn ($userAgent) => substr($userAgent, 0, 255));

// Control when to send new login notifications (e.g. disable in local/staging)
DeviceCreator::shouldSendNotificationUsing(function ($user, $device) {
return ! app()->environment('local');
});

// Customize the notification email
NewLoginDeviceNotification::toMailUsing(function ($notifiable, $device) {
$expire = Config::get('auth.verification.expire', 60);
Expand Down Expand Up @@ -130,11 +135,26 @@ use UserDevices\DeviceCreator;
DeviceCreator::ignoreNotification();
```

To control notifications globally (e.g. disable in local/staging, or custom logic per user/device):

```php
use UserDevices\DeviceCreator;

// Disable notifications entirely
DeviceCreator::shouldSendNotificationUsing(fn () => false);

// Custom logic (e.g. skip for admins)
DeviceCreator::shouldSendNotificationUsing(fn ($user, $device) => ! $user->isAdmin());

// Only send in production
DeviceCreator::shouldSendNotificationUsing(fn ($user, $device) => app()->environment('production'));
```

This will:

- Create or update the device record (IP + user agent)
- Update last activity timestamp
- Send a notification email on **first login** from that device (unless `ignoreNotification()` was called)
- Send a notification email on **first login** from that device (unless `ignoreNotification()` was called or `shouldSendNotificationUsing()` returns false)

#### 3. Block Device Route

Expand Down Expand Up @@ -204,6 +224,7 @@ $user->sendNewLoginDeviceNotification($device);
DeviceCreator::useUserModel(string $model): void
DeviceCreator::useUserDeviceModel(string $model): void
DeviceCreator::userAgentUsing(Closure $callback): void
DeviceCreator::shouldSendNotificationUsing(Closure $callback): void // (user, device) => bool

// Methods
DeviceCreator::ignoreNotification(): void // Add to Context to skip notification for current request
Expand Down
19 changes: 19 additions & 0 deletions src/DeviceCreator.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ class DeviceCreator
*/
public static string $userModel = 'App\\Models\\User';

/**
* A custom callback to determine whether to send the new login device notification.
*
* When set, this closure receives the user and device and returns a boolean.
* If it returns false, the notification is not sent. When null, notifications
* are sent by default (unless ignoreNotification() was called for the request).
*/
public static ?Closure $shouldSendNotification = null;

/**
* The fully qualified class name of the user device model.
*
Expand Down Expand Up @@ -66,4 +75,14 @@ public static function useUserDeviceModel(string $model): void
{
static::$userDeviceModel = $model;
}

/**
* Set a callback to determine whether to send the new login device notification.
*
* The callback receives the user and device as arguments and should return a boolean.
*/
public static function shouldSendNotificationUsing(Closure $callback): void
{
static::$shouldSendNotification = $callback;
}
}
21 changes: 17 additions & 4 deletions src/Listeners/SaveUserDevice.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Request;
use UserDevices\DeviceCreator;
use UserDevices\Models\UserDevice;
use UserDevices\Traits\HasUserDevices;

class SaveUserDevice
Expand All @@ -23,21 +24,33 @@ public function handle(Authenticated $event): void
return;
}

$ignore = Context::get('user_devices.ignore_notification', false);

$device = $user->userDevices()->firstOrNew([
'ip_address' => Request::ip(),
'user_agent' => with(Request::userAgent(), DeviceCreator::$userAgent),
]);

tap($device->exists, function ($exists) use ($user, $device, $ignore) {
tap($device->exists, function ($exists) use ($user, $device) {
$device->fill([
'last_activity' => Carbon::now()->timestamp,
])->save();

if (! $exists && ! $ignore) {
$shouldSend = $this->shouldSendNotification($user, $device);

if (! $exists && $shouldSend) {
$user->sendNewLoginDeviceNotification($device);
}
});
}

/**
* Determine whether to send the new login device notification.
*/
private function shouldSendNotification(mixed $user, UserDevice $device): bool
{
if (is_callable(DeviceCreator::$shouldSendNotification)) {
return (DeviceCreator::$shouldSendNotification)($user, $device);
}

return ! Context::get('user_devices.ignore_notification', false);
}
}
32 changes: 32 additions & 0 deletions tests/Feature/UserDevicesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,38 @@
Notification::assertSentTo($user, NewLoginDeviceNotification::class);
});

test('it should not send notification when shouldSendNotificationUsing returns false', function () {
Notification::fake();

$user = User::factory()->create();

DeviceCreator::shouldSendNotificationUsing(fn () => false);

$this->actingAs($user)
->withHeader('User-Agent', 'Mozilla/5.0 Yet Another New Device')
->get('/dashboard');

Notification::assertNothingSent();

DeviceCreator::$shouldSendNotification = null;
});

test('it should send notification when shouldSendNotificationUsing returns true', function () {
Notification::fake();

$user = User::factory()->create();

DeviceCreator::shouldSendNotificationUsing(fn () => true);

$this->actingAs($user)
->withHeader('User-Agent', 'Mozilla/5.0 Custom Callback Device')
->get('/dashboard');

Notification::assertSentTo($user, NewLoginDeviceNotification::class);

DeviceCreator::$shouldSendNotification = null;
});

test('it should block device when accessing signed URL', function () {
$user = User::factory()->create();

Expand Down
10 changes: 10 additions & 0 deletions tests/Unit/DeviceCreatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,13 @@

expect(Context::get('user_devices.ignore_notification'))->toBeTrue();
});

test('it should set should send notification callback correctly', function () {
$callback = fn () => true;

DeviceCreator::shouldSendNotificationUsing($callback);

expect(DeviceCreator::$shouldSendNotification)->toBe($callback);

DeviceCreator::$shouldSendNotification = null;
});