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'); +}); 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 e58fdb53ba..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: @@ -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/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/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_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/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 53ed3946ab..79f7cbef67 100644
--- a/packages/user-device/src/Notifications/NewDeviceNotification.php
+++ b/packages/user-device/src/Notifications/NewDeviceNotification.php
@@ -15,18 +15,15 @@ class NewDeviceNotification extends Notification implements ShouldQueue
use Queueable;
/**
- * Create a new notification instance.
- *
- * @return void
+ * @param array