From 5d24004199518798280b02fd49f922ec84601515 Mon Sep 17 00:00:00 2001 From: Reinhold-Jesse <88349887+Reinhold-Jesse@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:57:10 +0200 Subject: [PATCH 1/6] feat(user-device): send notify-only mail and soft-couple mail-template Allow new-device notifications when enforce_trust is off, support UUID morphs and Contact labels, and render via MailTemplateBridge when available. Co-authored-by: Cursor --- packages/user-device/README.md | 4 +- packages/user-device/config/user-device.php | 18 ++- .../create_user_devices_table.php.stub | 5 +- .../resources/views/mail/new-device.blade.php | 18 +-- .../resources/views/mail/raw-html.blade.php | 1 + .../Notifications/NewDeviceNotification.php | 116 +++++++++++++++--- .../src/Resources/UserDeviceResource.php | 44 ++++++- .../src/Services/UserDeviceTracker.php | 2 +- 8 files changed, 175 insertions(+), 33 deletions(-) create mode 100644 packages/user-device/resources/views/mail/raw-html.blade.php diff --git a/packages/user-device/README.md b/packages/user-device/README.md index e58fdb53ba..39eb7ae4ca 100644 --- a/packages/user-device/README.md +++ b/packages/user-device/README.md @@ -49,7 +49,7 @@ Curious what the install command does? See manual installation below. ### Track-only mode (`enforce_trust=false`) - Devices are still created/updated on login and linked to the session. -- No hard-block middleware and no new-device email. +- No hard-block middleware; new-device email is still sent (notify-only, without trust CTA). - New devices are stored as trusted (`whitelisted=true`). ### What the package ships @@ -83,7 +83,7 @@ Curious what the install command does? See manual installation below. ### Configuration (config/user-device.php) - `enabled` (bool): device tracking on login + session sync (default: false, env: `USER_DEVICE_ENABLED`) -- `enforce_trust` (bool): hard-block + trust mail for new devices (default: true, env: `USER_DEVICE_ENFORCE_TRUST`) +- `enforce_trust` (bool): hard-block + trust CTA in mail (default: true, env: `USER_DEVICE_ENFORCE_TRUST`). New-device mail is sent whenever tracking is enabled. - `trust_link_expires_minutes` (int): signed trust link expiry - `scope_to_authenticated_user` (bool): always scope resource to the current user - `allow_all_devices_without_shield` (bool): allow viewing all devices if Shield is not installed diff --git a/packages/user-device/config/user-device.php b/packages/user-device/config/user-device.php index 7f1a9c485e..50f513854c 100644 --- a/packages/user-device/config/user-device.php +++ b/packages/user-device/config/user-device.php @@ -35,8 +35,10 @@ |-------------------------------------------------------------------------- | | When true (default), untrusted devices are hard-blocked in Filament until - | confirmed via email trust link or admin Trust action. - | When false, devices are tracked only — no login gate. + | confirmed via email trust link or admin Trust action, and the new-device + | mail includes a trust CTA. + | When false, devices are tracked only — no login gate; new-device mail is + | still sent without a trust CTA (notify-only). | */ 'enforce_trust' => env('USER_DEVICE_ENFORCE_TRUST', true), @@ -141,10 +143,22 @@ |-------------------------------------------------------------------------- | | Either a full URL (https://...) or a public path (/logo/foo.svg). + | Used only for the Blade fallback when moox/mail-template is unavailable. | */ 'mail_logo_url' => '/logo/logo_heco_2021.svg', + /* + |-------------------------------------------------------------------------- + | Mail template slug + |-------------------------------------------------------------------------- + | + | When moox/mail-template is installed, NewDeviceNotification renders this + | MailTemplate slug (layout login-link) instead of the package Blade view. + | + */ + 'mail_template_slug' => env('USER_DEVICE_MAIL_TEMPLATE_SLUG', 'new-device'), + /* |-------------------------------------------------------------------------- | Audit defaults diff --git a/packages/user-device/database/migrations/create_user_devices_table.php.stub b/packages/user-device/database/migrations/create_user_devices_table.php.stub index 3a05a323b7..1f6f89d2bb 100644 --- a/packages/user-device/database/migrations/create_user_devices_table.php.stub +++ b/packages/user-device/database/migrations/create_user_devices_table.php.stub @@ -15,7 +15,10 @@ return new class extends Migration $table->string('slug')->unique(); $table->string('scope')->nullable()->index(); - $table->morphs('user'); + // string morph: supports bigint user ids and UUID authenticatables (e.g. contacts) + $table->string('user_id'); + $table->string('user_type'); + $table->index(['user_id', 'user_type']); $table->text('user_agent')->nullable(); $table->string('os')->nullable(); diff --git a/packages/user-device/resources/views/mail/new-device.blade.php b/packages/user-device/resources/views/mail/new-device.blade.php index 4a9b1500c7..c7c824d21c 100644 --- a/packages/user-device/resources/views/mail/new-device.blade.php +++ b/packages/user-device/resources/views/mail/new-device.blade.php @@ -79,19 +79,19 @@
  • {{ __('user-device::translations.mail_step_check_mfa') }}
  • -
    - @if(filled($trustUrl)) + @if(($enforceTrust ?? true) && filled($trustUrl)) +
    {{ __('user-device::translations.mail_cta_trust_device') }} - @endif -
    +
    -

    - - {{ __('user-device::translations.mail_cta_review_devices') }} - -

    +

    + + {{ __('user-device::translations.mail_cta_review_devices') }} + +

    + @endif

    {{ __('user-device::translations.mail_outro_secure_account') }} diff --git a/packages/user-device/resources/views/mail/raw-html.blade.php b/packages/user-device/resources/views/mail/raw-html.blade.php new file mode 100644 index 0000000000..974f823e16 --- /dev/null +++ b/packages/user-device/resources/views/mail/raw-html.blade.php @@ -0,0 +1 @@ +{!! $html !!} diff --git a/packages/user-device/src/Notifications/NewDeviceNotification.php b/packages/user-device/src/Notifications/NewDeviceNotification.php index 53ed3946ab..f454a10f69 100644 --- a/packages/user-device/src/Notifications/NewDeviceNotification.php +++ b/packages/user-device/src/Notifications/NewDeviceNotification.php @@ -15,18 +15,13 @@ class NewDeviceNotification extends Notification implements ShouldQueue use Queueable; /** - * Create a new notification instance. - * - * @return void + * @param array $deviceDetails */ - public function __construct(protected $deviceDetails) - { - } + public function __construct(protected array $deviceDetails) {} /** - * Get the notification's delivery channels. - * * @param mixed $notifiable + * @return array */ public function via($notifiable): array { @@ -34,15 +29,23 @@ public function via($notifiable): array } /** - * Get the mail representation of the notification. - * * @param mixed $notifiable - * @return MailMessage */ - public function toMail($notifiable) + public function toMail($notifiable): MailMessage { + $subject = __('user-device::translations.mail_subject_new_device'); + $data = $this->mailTemplateData($notifiable); + + $html = $this->renderMailTemplate($data); + + if (is_string($html) && $html !== '') { + return (new MailMessage) + ->subject($subject) + ->view('user-device::mail.raw-html', ['html' => $html]); + } + return (new MailMessage) - ->subject(__('user-device::translations.mail_subject_new_device')) + ->subject($subject) ->view('user-device::mail.new-device', [ 'notifiable' => $notifiable, 'deviceTitle' => $this->deviceDetails['title'] ?? null, @@ -52,12 +55,92 @@ public function toMail($notifiable) 'deviceOs' => $this->deviceDetails['os'] ?? null, 'deviceCity' => $this->deviceDetails['city'] ?? null, 'deviceCountry' => $this->deviceDetails['country'] ?? null, - 'reviewUrl' => $this->getReviewDevicesUrl(), - 'trustUrl' => $this->getTrustUrl($notifiable), + 'reviewUrl' => $data['reviewUrl'], + 'trustUrl' => $data['magicLink'], 'logoUrl' => $this->getLogoUrl(), + 'enforceTrust' => (bool) config('user-device.enforce_trust', true), ]); } + /** + * @param mixed $notifiable + * @return array + */ + protected function mailTemplateData(mixed $notifiable): array + { + $headline = __('user-device::translations.mail_title_new_device'); + $reviewUrl = $this->getReviewDevicesUrl(); + $trustUrl = config('user-device.enforce_trust', true) + ? $this->getTrustUrl($notifiable) + : null; + $expiresMinutes = (int) config('user-device.trust_link_expires_minutes', 60); + + return [ + 'title' => $headline, + 'headline' => $headline, + 'displayName' => $this->displayName($notifiable), + 'email' => $this->emailAddress($notifiable), + 'user' => $notifiable, + 'subject' => $notifiable, + 'deviceTitle' => (string) ($this->deviceDetails['title'] ?? ''), + 'deviceSystem' => collect([ + $this->deviceDetails['platform'] ?? null, + $this->deviceDetails['browser'] ?? null, + $this->deviceDetails['os'] ?? null, + ])->filter()->implode(' · '), + 'deviceIp' => (string) ($this->deviceDetails['ip_address'] ?? ''), + 'deviceLocation' => collect([ + $this->deviceDetails['city'] ?? null, + $this->deviceDetails['country'] ?? null, + ])->filter()->implode(', '), + 'magicLink' => $trustUrl ?? $reviewUrl, + 'reviewUrl' => $reviewUrl, + 'expiresMinutes' => $expiresMinutes, + ]; + } + + protected function emailAddress(mixed $notifiable): string + { + return trim((string) data_get($notifiable, 'email', '')); + } + + /** + * Soft-couple to moox/mail-template when available (no hard composer require). + * + * @param array $data + */ + protected function renderMailTemplate(array $data): ?string + { + $bridge = 'Moox\\MailTemplate\\Support\\MailTemplateBridge'; + + if (! class_exists($bridge) || ! $bridge::isAvailable()) { + return null; + } + + $slug = trim((string) config('user-device.mail_template_slug', 'new-device')); + + if ($slug === '') { + return null; + } + + $html = $bridge::toHtmlBySlug($slug, $data); + + return is_string($html) && $html !== '' ? $html : null; + } + + protected function displayName(mixed $notifiable): string + { + $firstName = trim((string) data_get($notifiable, 'first_name', '')); + $lastName = trim((string) data_get($notifiable, 'last_name', '')); + $fullName = trim($firstName.' '.$lastName); + + if ($fullName !== '') { + return $fullName; + } + + return trim((string) data_get($notifiable, 'name', data_get($notifiable, 'display_name', ''))); + } + protected function getReviewDevicesUrl(): string { $panelId = $this->deviceDetails['panel_id'] ?? null; @@ -107,9 +190,8 @@ protected function getTrustUrl(mixed $notifiable): ?string } /** - * Get the array representation of the notification. - * * @param mixed $notifiable + * @return array */ public function toArray($notifiable): array { diff --git a/packages/user-device/src/Resources/UserDeviceResource.php b/packages/user-device/src/Resources/UserDeviceResource.php index b4ea277aa4..59e1aa269a 100644 --- a/packages/user-device/src/Resources/UserDeviceResource.php +++ b/packages/user-device/src/Resources/UserDeviceResource.php @@ -16,6 +16,7 @@ use Filament\Tables\Filters\Filter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Schema as DbSchema; use Moox\Core\Entities\Items\Item\BaseItemResource; use Moox\Core\Support\Resources\Concerns\HasScopedChildResource; @@ -194,7 +195,10 @@ public static function table(Table $table): Table ->sortable(), TextColumn::make('user_id') ->label(__('core::user.user_id')) - ->getStateUsing(fn ($record) => optional($record->user)->name ?? 'unknown') + ->getStateUsing(fn (UserDevice $record): string => static::resolveUserLabel($record->user)) + ->description(fn (UserDevice $record): ?string => filled($record->user_type) + ? class_basename((string) $record->user_type) + : null) ->sortable(), static::getScopeTableColumn(), TextColumn::make('ip_address') @@ -234,6 +238,8 @@ public static function table(Table $table): Table $sub ->orWhere('name', 'like', "%{$q}%") ->orWhere('email', 'like', "%{$q}%") + ->orWhere('username', 'like', "%{$q}%") + ->orWhere('display_name', 'like', "%{$q}%") ->orWhere('first_name', 'like', "%{$q}%") ->orWhere('last_name', 'like', "%{$q}%"); }); @@ -290,6 +296,42 @@ public static function table(Table $table): Table ]); } + /** + * Resolve a human label for polymorphic authenticatables (User, Contact, …). + */ + public static function resolveUserLabel(?Model $user): string + { + if (! $user instanceof Model) { + return 'unknown'; + } + + if (method_exists($user, 'getFilamentName')) { + $label = trim((string) $user->getFilamentName()); + + if ($label !== '') { + return $label; + } + } + + if (method_exists($user, 'displayLabel')) { + $label = trim((string) $user->displayLabel()); + + if ($label !== '') { + return $label; + } + } + + foreach (['name', 'display_name', 'email', 'username'] as $attribute) { + $value = $user->getAttribute($attribute); + + if (filled($value)) { + return (string) $value; + } + } + + return (string) $user->getKey(); + } + #[Override] public static function getRelations(): array { diff --git a/packages/user-device/src/Services/UserDeviceTracker.php b/packages/user-device/src/Services/UserDeviceTracker.php index f1310a3fdf..b23ea92070 100644 --- a/packages/user-device/src/Services/UserDeviceTracker.php +++ b/packages/user-device/src/Services/UserDeviceTracker.php @@ -66,7 +66,7 @@ public function addUserDevice(Request $request, Authenticatable $user, Agent $ag Log::warning('The session-table does not have a device_id column. Install Moox User Devices package to add this feature.'); } - if ($device->wasRecentlyCreated && config('user-device.enforce_trust', true) && method_exists($user, 'notify')) { + if ($device->wasRecentlyCreated && method_exists($user, 'notify')) { $panelId = class_exists(Filament::class) ? Filament::getCurrentPanel()?->getId() : null; From de752a0dca44785041836d0e1c9a603777ca1464 Mon Sep 17 00:00:00 2001 From: Reinhold-Jesse <88349887+Reinhold-Jesse@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:01:06 +0000 Subject: [PATCH 2/6] Fix styling --- .../user-device/src/Notifications/NewDeviceNotification.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/user-device/src/Notifications/NewDeviceNotification.php b/packages/user-device/src/Notifications/NewDeviceNotification.php index f454a10f69..5b98e6b034 100644 --- a/packages/user-device/src/Notifications/NewDeviceNotification.php +++ b/packages/user-device/src/Notifications/NewDeviceNotification.php @@ -17,7 +17,9 @@ class NewDeviceNotification extends Notification implements ShouldQueue /** * @param array $deviceDetails */ - public function __construct(protected array $deviceDetails) {} + public function __construct(protected array $deviceDetails) + { + } /** * @param mixed $notifiable @@ -63,7 +65,6 @@ public function toMail($notifiable): MailMessage } /** - * @param mixed $notifiable * @return array */ protected function mailTemplateData(mixed $notifiable): array From e95a3f4514bc4be07f809e16b0abe07f7be39b6f Mon Sep 17 00:00:00 2001 From: Reinhold-Jesse <88349887+Reinhold-Jesse@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:00:21 +0200 Subject: [PATCH 3/6] fix(user-device): leave magicLink empty when enforce_trust is off Avoid falling back to the devices review URL so notify-only hosts do not get an accidental CTA token. Co-authored-by: Cursor --- .../user-device/src/Notifications/NewDeviceNotification.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/user-device/src/Notifications/NewDeviceNotification.php b/packages/user-device/src/Notifications/NewDeviceNotification.php index 5b98e6b034..3c853bc32b 100644 --- a/packages/user-device/src/Notifications/NewDeviceNotification.php +++ b/packages/user-device/src/Notifications/NewDeviceNotification.php @@ -94,7 +94,10 @@ protected function mailTemplateData(mixed $notifiable): array $this->deviceDetails['city'] ?? null, $this->deviceDetails['country'] ?? null, ])->filter()->implode(', '), - 'magicLink' => $trustUrl ?? $reviewUrl, + // Notify-only: never fall back to reviewUrl (heco templates omit CTA). + 'magicLink' => config('user-device.enforce_trust', true) + ? ($trustUrl ?? $reviewUrl) + : '', 'reviewUrl' => $reviewUrl, 'expiresMinutes' => $expiresMinutes, ]; From db21cf187bbc27951c9c71141fd0415de0d34eff Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:32:55 +0200 Subject: [PATCH 4/6] block untrusted logins with toast and use template subject --- .../src/Support/MailTemplateBridge.php | 38 +++++++++++++++ packages/user-device/README.md | 6 +-- .../resources/lang/de/translations.php | 5 +- .../resources/lang/en/translations.php | 5 +- .../Controllers/TrustDeviceController.php | 35 +++++++++++++- .../Http/Middleware/EnsureTrustedDevice.php | 45 +++++------------- .../Notifications/NewDeviceNotification.php | 46 +++++++++++++++---- .../src/Policies/UserDevicePolicy.php | 14 +++++- .../src/Resources/UserDeviceResource.php | 37 +++++++++++++-- 9 files changed, 174 insertions(+), 57 deletions(-) diff --git a/packages/mail-template/src/Support/MailTemplateBridge.php b/packages/mail-template/src/Support/MailTemplateBridge.php index 9ec349c3a7..d898a5be04 100644 --- a/packages/mail-template/src/Support/MailTemplateBridge.php +++ b/packages/mail-template/src/Support/MailTemplateBridge.php @@ -219,4 +219,42 @@ public static function toHtmlBySlug(string $slug, array $data = [], ?string $loc return $renderer->toHtml($template, $data); } + + /** + * Localized template title (Filament “Betreff”) for use as the email subject. + */ + public static function titleBySlug(string $slug, ?string $locale = null): ?string + { + $slug = trim($slug); + + if ($slug === '' || ! self::isAvailable()) { + return null; + } + + $template = app(MailTemplateRenderer::class)->find($slug, $locale); + + if ($template === null) { + return null; + } + + $translationLocale = method_exists($template, 'getDefaultLocale') + ? (string) $template->getDefaultLocale() + : (string) ($locale ?? app()->getLocale()); + + $translation = null; + + if (method_exists($template, 'getTranslation')) { + $translation = $template->getTranslation($translationLocale, false); + } + + if ($translation === null) { + $translation = $template->translations->first( + fn ($row): bool => strcasecmp((string) ($row->locale ?? ''), $translationLocale) === 0 + ) ?? $template->translations->first(); + } + + $title = trim((string) ($translation?->title ?? '')); + + return $title !== '' ? $title : null; + } } diff --git a/packages/user-device/README.md b/packages/user-device/README.md index 39eb7ae4ca..991c451b1f 100644 --- a/packages/user-device/README.md +++ b/packages/user-device/README.md @@ -34,12 +34,12 @@ Curious what the install command does? See manual installation below. - If the record is **new**, the user receives an email with a **signed “Trust this device” link**. 2. **Device is untrusted (`whitelisted = false`)** - - The user is **hard-blocked** in Filament: any attempt to navigate away will be redirected back to the devices page with a single notification. - - The user must click the **email trust link** to continue. + - Panel access is blocked: the user is logged out and returned to the **login screen** with a toast (check email / confirm device). + - The user must click the **email trust link** before they can sign in. 3. **Trusting a device** - The signed link marks the device as `whitelisted = true`. - - After that, the user can use Filament normally. + - After that, the user can sign in and use Filament normally. 4. **Admin actions** - If Filament Shield / Spatie Permission is available, **super_admin** can: diff --git a/packages/user-device/resources/lang/de/translations.php b/packages/user-device/resources/lang/de/translations.php index e3b842ab55..151df5175d 100644 --- a/packages/user-device/resources/lang/de/translations.php +++ b/packages/user-device/resources/lang/de/translations.php @@ -19,6 +19,7 @@ 'mail_label_system' => 'System', 'mail_label_ip' => 'IP-Adresse', 'mail_label_location' => 'Ort', + 'mail_location_unknown' => 'Nicht ermittelbar', 'mail_if_it_was_you' => 'Wenn du das selbst warst, kannst du diese E‑Mail ignorieren.', 'mail_if_it_was_not_you' => 'Wenn du das nicht warst:', 'mail_step_review_devices' => 'prüfe deine Geräte‑Liste', @@ -29,8 +30,8 @@ 'mail_outro_secure_account' => 'Wenn das nicht du warst, sichere bitte dein Benutzerkonto.', // Enforcement - 'device_blocked_title' => 'Geräte-Bestätigung erforderlich', - 'device_blocked_body' => 'Bitte bestätige dieses Gerät über den Link aus der E‑Mail, die wir dir gesendet haben.', + 'device_blocked_title' => 'Neues Gerät erkannt', + 'device_blocked_body' => 'Dieses Gerät ist noch nicht bestätigt. Wir haben dir eine E‑Mail geschickt. Bitte öffne den Bestätigungslink darin, danach kannst du dich anmelden.', // Devices 'device_trusted' => 'Vertraut', diff --git a/packages/user-device/resources/lang/en/translations.php b/packages/user-device/resources/lang/en/translations.php index 9601c5aa26..8294bc3e58 100644 --- a/packages/user-device/resources/lang/en/translations.php +++ b/packages/user-device/resources/lang/en/translations.php @@ -26,6 +26,7 @@ 'mail_label_system' => 'System', 'mail_label_ip' => 'IP address', 'mail_label_location' => 'Location', + 'mail_location_unknown' => 'Unknown', 'mail_if_it_was_you' => 'If this was you, you can ignore this email.', 'mail_if_it_was_not_you' => 'If this was not you:', 'mail_step_review_devices' => 'Review your devices', @@ -36,8 +37,8 @@ 'mail_outro_secure_account' => 'If this was not you, please secure your account.', // Enforcement - 'device_blocked_title' => 'Device confirmation required', - 'device_blocked_body' => 'Please confirm this device using the link from the email we sent you.', + 'device_blocked_title' => 'New device detected', + 'device_blocked_body' => 'This device has not been confirmed yet. We sent you an email. Please open the confirmation link in it, then you can sign in.', // Devices 'device_trusted' => 'Trusted', diff --git a/packages/user-device/src/Http/Controllers/TrustDeviceController.php b/packages/user-device/src/Http/Controllers/TrustDeviceController.php index 53c958a673..2ab1b5f7b7 100644 --- a/packages/user-device/src/Http/Controllers/TrustDeviceController.php +++ b/packages/user-device/src/Http/Controllers/TrustDeviceController.php @@ -4,19 +4,23 @@ namespace Moox\UserDevice\Http\Controllers; +use Filament\Facades\Filament; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Moox\UserDevice\Models\UserDevice; use Moox\UserDevice\Resources\UserDeviceResource; +use Throwable; class TrustDeviceController { public function __invoke(Request $request, string $panel, int $device): RedirectResponse { + $this->setFilamentPanel($panel); + if (! filament()->auth()->check()) { session()->put('url.intended', $request->fullUrl()); - return redirect()->to(UserDeviceResource::getUrl('index', panel: $panel)); + return redirect()->to($this->loginUrl($panel)); } $authUser = filament()->auth()->user(); @@ -29,6 +33,33 @@ public function __invoke(Request $request, string $panel, int $device): Redirect $record->update(['whitelisted' => true]); - return redirect()->to(UserDeviceResource::getUrl('index', panel: $panel)); + try { + $home = filament()->getUrl(); + if (filled($home)) { + return redirect()->to($home); + } + } catch (Throwable) { + // + } + + return redirect()->to($this->loginUrl($panel)); + } + + protected function setFilamentPanel(string $panelId): void + { + try { + Filament::setCurrentPanel(Filament::getPanel($panelId)); + } catch (Throwable) { + // Invalid panel id — leave default panel; auth/ownership checks still apply. + } + } + + protected function loginUrl(string $panelId): string + { + try { + return filament()->getLoginUrl() ?? UserDeviceResource::getUrl('index', panel: $panelId); + } catch (Throwable) { + return UserDeviceResource::getUrl('index', panel: $panelId); + } } } diff --git a/packages/user-device/src/Http/Middleware/EnsureTrustedDevice.php b/packages/user-device/src/Http/Middleware/EnsureTrustedDevice.php index db7693679d..6a5cf63af4 100644 --- a/packages/user-device/src/Http/Middleware/EnsureTrustedDevice.php +++ b/packages/user-device/src/Http/Middleware/EnsureTrustedDevice.php @@ -5,16 +5,15 @@ namespace Moox\UserDevice\Http\Middleware; use Closure; -use Filament\Facades\Filament; use Filament\Notifications\Notification; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Moox\UserDevice\Models\UserDevice; -use Moox\UserDevice\Resources\UserDeviceResource; use Spatie\Permission\PermissionRegistrar; use Symfony\Component\HttpFoundation\Response; +use Throwable; class EnsureTrustedDevice { @@ -38,13 +37,6 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - $path = '/'.ltrim((string) $request->path(), '/'); - - // Always allow navigating to device management (otherwise users get stuck). - if ($this->isUserDeviceResourceRequest($path)) { - return $next($request); - } - $sessionId = session()->getId(); if (blank($sessionId)) { @@ -58,8 +50,9 @@ public function handle(Request $request, Closure $next): Response } // Fallback: resolve device by user+ip (covers session-regeneration edge cases). + // Use the Filament-authenticated user id — Auth::id() is the wrong guard on portal. if (blank($deviceId)) { - $userId = Auth::id(); + $userId = $user->getAuthIdentifier(); if (blank($userId)) { return $next($request); } @@ -100,41 +93,25 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - // Hard block: while untrusted, the user can only access the devices page + trust link. + // Untrusted: kick back to the login screen with a toast (no panel access). Notification::make() ->title(__('user-device::translations.device_blocked_title')) ->body(__('user-device::translations.device_blocked_body')) ->danger() ->send(); - $panelId = filament()->getCurrentPanel()?->getId(); - - $devicesUrl = filled($panelId) - ? UserDeviceResource::getUrl('index', panel: $panelId) - : UserDeviceResource::getUrl('index'); + filament()->auth()->logout(); - return redirect()->to($devicesUrl); + return redirect()->to($this->loginUrl()); } - private function isUserDeviceResourceRequest(string $path): bool + private function loginUrl(): string { - if (! class_exists(Filament::class)) { - return false; + try { + return (string) (filament()->getLoginUrl() ?? '/'); + } catch (Throwable) { + return '/'; } - - foreach (Filament::getPanels() as $panel) { - try { - $devicesIndexPath = parse_url(UserDeviceResource::getUrl('index', panel: $panel->getId()), PHP_URL_PATH); - - if (is_string($devicesIndexPath) && $devicesIndexPath !== '' && str_starts_with($path, $devicesIndexPath)) { - return true; - } - } catch (\Throwable) { - // ignore panels where the resource is not registered - } - } - - return false; } private function isShieldAdmin(object $user): bool diff --git a/packages/user-device/src/Notifications/NewDeviceNotification.php b/packages/user-device/src/Notifications/NewDeviceNotification.php index 3c853bc32b..38904637fe 100644 --- a/packages/user-device/src/Notifications/NewDeviceNotification.php +++ b/packages/user-device/src/Notifications/NewDeviceNotification.php @@ -17,9 +17,7 @@ class NewDeviceNotification extends Notification implements ShouldQueue /** * @param array $deviceDetails */ - public function __construct(protected array $deviceDetails) - { - } + public function __construct(protected array $deviceDetails) {} /** * @param mixed $notifiable @@ -35,9 +33,8 @@ public function via($notifiable): array */ public function toMail($notifiable): MailMessage { - $subject = __('user-device::translations.mail_subject_new_device'); $data = $this->mailTemplateData($notifiable); - + $subject = $this->resolveSubject(); $html = $this->renderMailTemplate($data); if (is_string($html) && $html !== '') { @@ -90,10 +87,7 @@ protected function mailTemplateData(mixed $notifiable): array $this->deviceDetails['os'] ?? null, ])->filter()->implode(' · '), 'deviceIp' => (string) ($this->deviceDetails['ip_address'] ?? ''), - 'deviceLocation' => collect([ - $this->deviceDetails['city'] ?? null, - $this->deviceDetails['country'] ?? null, - ])->filter()->implode(', '), + 'deviceLocation' => $this->deviceLocation(), // Notify-only: never fall back to reviewUrl (heco templates omit CTA). 'magicLink' => config('user-device.enforce_trust', true) ? ($trustUrl ?? $reviewUrl) @@ -108,6 +102,40 @@ protected function emailAddress(mixed $notifiable): string return trim((string) data_get($notifiable, 'email', '')); } + protected function resolveSubject(): string + { + $fallback = __('user-device::translations.mail_subject_new_device'); + $bridge = 'Moox\\MailTemplate\\Support\\MailTemplateBridge'; + + if (! class_exists($bridge) || ! $bridge::isAvailable()) { + return $fallback; + } + + $slug = trim((string) config('user-device.mail_template_slug', 'new-device')); + + if ($slug === '') { + return $fallback; + } + + $title = $bridge::titleBySlug($slug); + + return filled($title) ? (string) $title : $fallback; + } + + protected function deviceLocation(): string + { + $location = collect([ + $this->deviceDetails['city'] ?? null, + $this->deviceDetails['country'] ?? null, + ])->filter()->implode(', '); + + if ($location !== '') { + return $location; + } + + return __('user-device::translations.mail_location_unknown'); + } + /** * Soft-couple to moox/mail-template when available (no hard composer require). * diff --git a/packages/user-device/src/Policies/UserDevicePolicy.php b/packages/user-device/src/Policies/UserDevicePolicy.php index 72bf48c69d..f5d3a39ad0 100644 --- a/packages/user-device/src/Policies/UserDevicePolicy.php +++ b/packages/user-device/src/Policies/UserDevicePolicy.php @@ -41,7 +41,19 @@ public function update(Authorizable $user, Model $record): bool public function delete(Authorizable $user, Model $record): bool { - return $this->isShieldAdmin($user); + if ($this->isShieldAdmin($user)) { + return true; + } + + // Without Shield, Filament Admin still needs delete for ops. Portal + // never shows the delete action (canManageDevicesAsAdmin is admin-only). + return $this->allowOpsWithoutShield(); + } + + protected function allowOpsWithoutShield(): bool + { + return ! $this->permissionSystemAvailable() + && (bool) config('user-device.allow_all_devices_without_shield', false); } protected function permissionSystemAvailable(): bool diff --git a/packages/user-device/src/Resources/UserDeviceResource.php b/packages/user-device/src/Resources/UserDeviceResource.php index 59e1aa269a..151cf16f90 100644 --- a/packages/user-device/src/Resources/UserDeviceResource.php +++ b/packages/user-device/src/Resources/UserDeviceResource.php @@ -98,6 +98,12 @@ protected static function shouldScopeToAuthenticatedUser(): bool return false; } + // Self-service panels (e.g. portal) always see own devices only. + $panelId = filament()->getCurrentPanel()?->getId(); + if (is_string($panelId) && $panelId !== 'admin') { + return true; + } + if (! static::permissionSystemAvailable()) { return ! config('user-device.allow_all_devices_without_shield', false); } @@ -253,14 +259,14 @@ public static function table(Table $table): Table ->requiresConfirmation() ->modalHeading(__('user-device::translations.device_delete_modal_heading')) ->modalDescription(__('user-device::translations.device_delete_modal_description')) - ->visible(fn (UserDevice $record): bool => static::permissionSystemAvailable() && static::isShieldAdmin(filament()->auth()->user())) + ->visible(fn (UserDevice $record): bool => static::canManageDevicesAsAdmin()) ->successNotificationTitle(__('user-device::translations.device_delete_success_title')), Action::make('trust') ->label(__('user-device::translations.device_trust')) ->requiresConfirmation() ->modalHeading(__('user-device::translations.device_trust_modal_heading')) ->modalDescription(__('user-device::translations.device_trust_modal_description')) - ->visible(fn (UserDevice $record): bool => static::permissionSystemAvailable() && static::isShieldAdmin(filament()->auth()->user()) && ! $record->whitelisted) + ->visible(fn (UserDevice $record): bool => ! $record->whitelisted && static::canManageDevicesAsAdmin()) ->action(function (UserDevice $record): void { $record->update(['whitelisted' => true]); @@ -274,7 +280,7 @@ public static function table(Table $table): Table ->requiresConfirmation() ->modalHeading(__('user-device::translations.device_untrust_modal_heading')) ->modalDescription(__('user-device::translations.device_untrust_modal_description')) - ->visible(fn (UserDevice $record): bool => static::permissionSystemAvailable() && static::isShieldAdmin(filament()->auth()->user()) && $record->whitelisted) + ->visible(fn (UserDevice $record): bool => (bool) $record->whitelisted && static::canManageDevicesAsAdmin()) ->action(function (UserDevice $record): void { $record->update(['whitelisted' => false]); @@ -292,10 +298,33 @@ public static function table(Table $table): Table ->modalHeading(__('user-device::translations.device_delete_modal_heading')) ->modalDescription(__('user-device::translations.device_delete_modal_description')) ->successNotificationTitle(__('user-device::translations.device_delete_success_title')) - ->visible(fn (): bool => static::permissionSystemAvailable() && static::isShieldAdmin(filament()->auth()->user())), + ->visible(fn (): bool => static::canManageDevicesAsAdmin()), ]); } + /** + * Admin ops only (Shield super_admin, or admin panel without Shield when allow_all is on). + * Portal users trust via the signed mail link — no self-service Trust button. + */ + public static function canManageDevicesAsAdmin(?object $user = null): bool + { + $user ??= filament()->auth()->user(); + + if (! $user) { + return false; + } + + if (static::permissionSystemAvailable() && static::isShieldAdmin($user)) { + return true; + } + + $panelId = filament()->getCurrentPanel()?->getId(); + + return $panelId === 'admin' + && ! static::permissionSystemAvailable() + && (bool) config('user-device.allow_all_devices_without_shield', false); + } + /** * Resolve a human label for polymorphic authenticatables (User, Contact, …). */ From 53130a5750d96ae8788f7b48f12b67062053b703 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:34:25 +0000 Subject: [PATCH 5/6] Fix styling --- .../user-device/src/Notifications/NewDeviceNotification.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/user-device/src/Notifications/NewDeviceNotification.php b/packages/user-device/src/Notifications/NewDeviceNotification.php index 38904637fe..79f7cbef67 100644 --- a/packages/user-device/src/Notifications/NewDeviceNotification.php +++ b/packages/user-device/src/Notifications/NewDeviceNotification.php @@ -17,7 +17,9 @@ class NewDeviceNotification extends Notification implements ShouldQueue /** * @param array $deviceDetails */ - public function __construct(protected array $deviceDetails) {} + public function __construct(protected array $deviceDetails) + { + } /** * @param mixed $notifiable From 7cc8f9a4603174e18402efef4f9fa2fbc402271a Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:22:29 +0200 Subject: [PATCH 6/6] allow guest access to user-device trust links --- .../src/Http/Middleware/FrontendAuthMiddleware.php | 5 +++++ .../tests/Feature/MooxFrontendAuthTest.php | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/frontend-auth/src/Http/Middleware/FrontendAuthMiddleware.php b/packages/frontend-auth/src/Http/Middleware/FrontendAuthMiddleware.php index a67ddacf4d..e9349d5f15 100644 --- a/packages/frontend-auth/src/Http/Middleware/FrontendAuthMiddleware.php +++ b/packages/frontend-auth/src/Http/Middleware/FrontendAuthMiddleware.php @@ -48,6 +48,11 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + // Device trust magic-links: signed + panel login handled in TrustDeviceController. + if (is_string($routeName) && $routeName === 'user-device.devices.trust') { + return $next($request); + } + $this->configureAuthFromConfig(); // Use Filament's auth check so we align with its panel access rules. diff --git a/packages/frontend-auth/tests/Feature/MooxFrontendAuthTest.php b/packages/frontend-auth/tests/Feature/MooxFrontendAuthTest.php index f036fc7fcb..91165f78a3 100644 --- a/packages/frontend-auth/tests/Feature/MooxFrontendAuthTest.php +++ b/packages/frontend-auth/tests/Feature/MooxFrontendAuthTest.php @@ -99,3 +99,16 @@ $response->assertOk(); $response->assertSeeText('consumed'); }); + +it('allows guest access to user-device trust magic-link routes', function () { + config()->set('moox-frontend-auth.enabled', true); + + Route::middleware(['web'])->get('/__user_device_trust_except', function () { + return response('trust-ok', 200); + })->name('user-device.devices.trust'); + + $response = $this->get('/__user_device_trust_except'); + + $response->assertOk(); + $response->assertSeeText('trust-ok'); +});