From e50e67e2abca0ee43d01d7f4246b8976f7450784 Mon Sep 17 00:00:00 2001
From: roble
Date: Sat, 25 Apr 2026 21:58:37 +0100
Subject: [PATCH 01/12] feat(auth): implement authentication features including
login, registration, password reset, and social login
- Add LoginController for handling user login and session management.
- Introduce MagicLinkController for magic link authentication.
- Create PasswordController for updating user passwords.
- Implement RegisterController for user registration and role assignment.
- Add ReimpersonateController for re-impersonating users.
- Create ResetPasswordController for handling password reset requests.
- Implement SocialiteController for social authentication and account linking.
- Add VerifyEmailController for email verification.
- Create middleware to ensure magic link functionality is enabled.
- Implement LoginRequest and RegisterRequest for request validation.
- Add listeners for user registration, impersonation events, and last login updates.
- Create models for MagicLinkToken and SocialAccount to manage tokens and social accounts.
- Implement notifications for magic link and welcome messages.
- Register AuthServiceProvider to share authentication data with Inertia.
- Create SocialiteService for handling social authentication logic.
- Add Sociable trait to manage user social accounts.
---
.github/workflows/release.yml | 2 +-
.github/workflows/test.yml | 2 +-
CLAUDE.md | 2 +-
Taskfile.yml | 4 +-
app/Providers/RouteServiceProvider.php | 17 ---
composer.json | 13 +-
database/seeders/DatabaseSeeder.php | 13 ++
module.json | 15 ---
routes/api.php | 6 +-
routes/web.php | 126 +++++++++---------
{app => src}/Exceptions/AuthException.php | 0
.../Exceptions/SocialiteException.php | 0
{app => src}/Filament/AuthPlugin.php | 0
.../Resources/Users/Pages/CreateUser.php | 0
.../Resources/Users/Pages/EditUser.php | 0
.../Resources/Users/Pages/ListUsers.php | 0
.../Resources/Users/Pages/ViewUser.php | 0
.../Resources/Users/Schemas/UserForm.php | 0
.../Resources/Users/Schemas/UserInfolist.php | 0
.../Resources/Users/Tables/UsersTable.php | 0
.../Filament/Resources/Users/UserResource.php | 0
{app => src}/Http/Controllers/Controller.php | 0
...mailVerificationNotificationController.php | 0
.../EmailVerificationPromptController.php | 0
.../Controllers/ForgotPasswordController.php | 0
.../Http/Controllers/LoginController.php | 0
.../Http/Controllers/MagicLinkController.php | 0
.../Http/Controllers/PasswordController.php | 0
.../Http/Controllers/RegisterController.php | 0
.../Controllers/ReimpersonateController.php | 0
.../Controllers/ResetPasswordController.php | 0
.../Http/Controllers/SocialiteController.php | 0
.../Controllers/VerifyEmailController.php | 0
.../Middleware/EnsureMagicLinkEnabled.php | 0
{app => src}/Http/Requests/LoginRequest.php | 0
.../Http/Requests/RegisterRequest.php | 0
{app => src}/Listeners/AssignUserRole.php | 0
{app => src}/Listeners/Impersonation.php | 0
.../Listeners/SendWelcomeNotification.php | 0
.../Listeners/UpdateUserLastLogin.php | 0
{app => src}/Models/MagicLinkToken.php | 0
{app => src}/Models/SocialAccount.php | 0
.../Notifications/MagicLinkNotification.php | 0
.../Notifications/WelcomeNotification.php | 0
.../Providers/AuthServiceProvider.php | 15 +--
{app => src}/Services/SocialiteService.php | 0
{app => src}/Traits/Sociable.php | 0
47 files changed, 96 insertions(+), 119 deletions(-)
delete mode 100644 app/Providers/RouteServiceProvider.php
create mode 100644 database/seeders/DatabaseSeeder.php
delete mode 100644 module.json
rename {app => src}/Exceptions/AuthException.php (100%)
rename {app => src}/Exceptions/SocialiteException.php (100%)
rename {app => src}/Filament/AuthPlugin.php (100%)
rename {app => src}/Filament/Resources/Users/Pages/CreateUser.php (100%)
rename {app => src}/Filament/Resources/Users/Pages/EditUser.php (100%)
rename {app => src}/Filament/Resources/Users/Pages/ListUsers.php (100%)
rename {app => src}/Filament/Resources/Users/Pages/ViewUser.php (100%)
rename {app => src}/Filament/Resources/Users/Schemas/UserForm.php (100%)
rename {app => src}/Filament/Resources/Users/Schemas/UserInfolist.php (100%)
rename {app => src}/Filament/Resources/Users/Tables/UsersTable.php (100%)
rename {app => src}/Filament/Resources/Users/UserResource.php (100%)
rename {app => src}/Http/Controllers/Controller.php (100%)
rename {app => src}/Http/Controllers/EmailVerificationNotificationController.php (100%)
rename {app => src}/Http/Controllers/EmailVerificationPromptController.php (100%)
rename {app => src}/Http/Controllers/ForgotPasswordController.php (100%)
rename {app => src}/Http/Controllers/LoginController.php (100%)
rename {app => src}/Http/Controllers/MagicLinkController.php (100%)
rename {app => src}/Http/Controllers/PasswordController.php (100%)
rename {app => src}/Http/Controllers/RegisterController.php (100%)
rename {app => src}/Http/Controllers/ReimpersonateController.php (100%)
rename {app => src}/Http/Controllers/ResetPasswordController.php (100%)
rename {app => src}/Http/Controllers/SocialiteController.php (100%)
rename {app => src}/Http/Controllers/VerifyEmailController.php (100%)
rename {app => src}/Http/Middleware/EnsureMagicLinkEnabled.php (100%)
rename {app => src}/Http/Requests/LoginRequest.php (100%)
rename {app => src}/Http/Requests/RegisterRequest.php (100%)
rename {app => src}/Listeners/AssignUserRole.php (100%)
rename {app => src}/Listeners/Impersonation.php (100%)
rename {app => src}/Listeners/SendWelcomeNotification.php (100%)
rename {app => src}/Listeners/UpdateUserLastLogin.php (100%)
rename {app => src}/Models/MagicLinkToken.php (100%)
rename {app => src}/Models/SocialAccount.php (100%)
rename {app => src}/Notifications/MagicLinkNotification.php (100%)
rename {app => src}/Notifications/WelcomeNotification.php (100%)
rename {app => src}/Providers/AuthServiceProvider.php (91%)
rename {app => src}/Services/SocialiteService.php (100%)
rename {app => src}/Traits/Sociable.php (100%)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e6d0709..160d817 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,7 +12,7 @@ jobs:
release:
uses: saucebase-dev/saucebase/.github/workflows/semantic-release.yml@main
with:
- version_file: 'module.json'
+ version_file: 'composer.json'
version_file_path: '.version'
prerelease: false
secrets:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9d842e7..b3ba07e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,4 +13,4 @@ jobs:
test:
uses: saucebase-dev/saucebase/.github/workflows/test-module.yml@main
with:
- module: Auth
+ module: auth
diff --git a/CLAUDE.md b/CLAUDE.md
index 4c2d686..c827c54 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,7 +74,7 @@ Redirect URIs default to `/auth/socialite/{provider}/callback`. Providers config
```bash
php artisan test --testsuite=Modules --filter='^Modules\\Auth\\Tests' # PHPUnit
-npx playwright test --project="@Auth*" # E2E
+npx playwright test --project="@auth*" # E2E
```
**E2E coverage**: login (basic, errors, security/rate-limiting, social, logout), register (basic, errors), forgot-password (basic, errors), verify-email. Page objects in `tests/e2e/pages/`, fixtures in `tests/e2e/fixtures/users.ts`.
diff --git a/Taskfile.yml b/Taskfile.yml
index 27b2c63..3e54da6 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -9,11 +9,11 @@ tasks:
test:e2e:
desc: Run E2E tests for Auth module
- cmd: npx playwright test --project="@Auth*" {{.CLI_ARGS}}
+ cmd: npx playwright test --project="@auth*" {{.CLI_ARGS}}
interactive: true
# ── Code Generation ────────────────────────────────────────────
types:generate:
desc: Generate TypeScript types from PHP DTOs and enums
- cmd: php artisan module:generate-types Auth
+ cmd: php artisan module:generate-types auth
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
deleted file mode 100644
index f5d5bef..0000000
--- a/app/Providers/RouteServiceProvider.php
+++ /dev/null
@@ -1,17 +0,0 @@
-group(module_path('auth', '/routes/web.php'));
- Route::middleware('api')
- ->group(module_path('auth', '/routes/api.php'));
- }
-}
diff --git a/composer.json b/composer.json
index 1ec903a..a5bf0b8 100644
--- a/composer.json
+++ b/composer.json
@@ -1,7 +1,9 @@
{
"name": "saucebase/auth",
- "description": "",
+ "description": "Authentication module for Saucebase",
"type": "saucebase-module",
+ "license": "proprietary",
+ "version": "1.8.1",
"authors": [
{
"name": "Saucebase",
@@ -10,13 +12,15 @@
],
"extra": {
"laravel": {
- "providers": [],
+ "providers": [
+ "Modules\\Auth\\Providers\\AuthServiceProvider"
+ ],
"aliases": {}
}
},
"autoload": {
"psr-4": {
- "Modules\\Auth\\": "app/",
+ "Modules\\Auth\\": "src/",
"Modules\\Auth\\Database\\Factories\\": "database/factories/",
"Modules\\Auth\\Database\\Seeders\\": "database/seeders/"
}
@@ -29,5 +33,6 @@
"require": {
"laravel/socialite": "^5.21",
"stechstudio/filament-impersonate": "^5.1"
- }
+ },
+ "minimum-stability": "stable"
}
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
new file mode 100644
index 0000000..5aa8a7d
--- /dev/null
+++ b/database/seeders/DatabaseSeeder.php
@@ -0,0 +1,13 @@
+call(AuthDatabaseSeeder::class);
+ }
+}
diff --git a/module.json b/module.json
deleted file mode 100644
index a9a6b1f..0000000
--- a/module.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "name": "Auth",
- "alias": "auth",
- "description": "Authentication module for Saucebase",
- "author": "Saucebase",
- "version": "v1.8.1",
- "keywords": [
- "auth"
- ],
- "priority": 0,
- "providers": [
- "Modules\\Auth\\Providers\\AuthServiceProvider"
- ],
- "files": []
-}
diff --git a/routes/api.php b/routes/api.php
index 5a7d39e..4d9a221 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -3,6 +3,8 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
-Route::middleware(['auth:sanctum'])->prefix('api/v1/auth')->group(function () {
- Route::get('me', fn () => response()->json(Auth::user()));
+Route::middleware('api')->group(function (): void {
+ Route::middleware(['auth:sanctum'])->prefix('api/v1/auth')->group(function (): void {
+ Route::get('me', fn () => response()->json(Auth::user()));
+ });
});
diff --git a/routes/web.php b/routes/web.php
index 0b34f42..3215399 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -13,85 +13,87 @@
use Modules\Auth\Http\Controllers\SocialiteController;
use Modules\Auth\Http\Controllers\VerifyEmailController;
-Route::prefix('auth')->group(function () {
- Route::middleware('guest')->group(function () {
+Route::middleware('web')->group(function (): void {
+ Route::prefix('auth')->group(function (): void {
+ Route::middleware('guest')->group(function (): void {
- Route::get('login', [LoginController::class, 'create'])
- ->name('login');
+ Route::get('login', [LoginController::class, 'create'])
+ ->name('login');
- Route::post('login', [LoginController::class, 'store']);
+ Route::post('login', [LoginController::class, 'store']);
- Route::get('register', [RegisterController::class, 'create'])
- ->name('register');
+ Route::get('register', [RegisterController::class, 'create'])
+ ->name('register');
- Route::post('register', [RegisterController::class, 'store']);
+ Route::post('register', [RegisterController::class, 'store']);
- Route::get('forgot-password', [ForgotPasswordController::class, 'create'])
- ->name('password.request');
+ Route::get('forgot-password', [ForgotPasswordController::class, 'create'])
+ ->name('password.request');
- Route::post('forgot-password', [ForgotPasswordController::class, 'store'])
- ->name('password.email');
+ Route::post('forgot-password', [ForgotPasswordController::class, 'store'])
+ ->name('password.email');
- Route::get('reset-password/{token}', [ResetPasswordController::class, 'create'])
- ->name('password.reset');
+ Route::get('reset-password/{token}', [ResetPasswordController::class, 'create'])
+ ->name('password.reset');
- Route::post('reset-password', [ResetPasswordController::class, 'store'])
- ->middleware('throttle:6,1')
- ->name('password.store');
+ Route::post('reset-password', [ResetPasswordController::class, 'store'])
+ ->middleware('throttle:6,1')
+ ->name('password.store');
- Route::get('magic-link', [MagicLinkController::class, 'create'])
- ->name('magic-link.create');
+ Route::get('magic-link', [MagicLinkController::class, 'create'])
+ ->name('magic-link.create');
- Route::post('magic-link', [MagicLinkController::class, 'store'])
- ->middleware('throttle:5,1')
- ->name('magic-link.store');
- });
+ Route::post('magic-link', [MagicLinkController::class, 'store'])
+ ->middleware('throttle:5,1')
+ ->name('magic-link.store');
+ });
- Route::middleware('auth')->group(function () {
+ Route::middleware('auth')->group(function (): void {
- Route::any('logout', [LoginController::class, 'destroy'])
- ->name('logout');
+ Route::any('logout', [LoginController::class, 'destroy'])
+ ->name('logout');
- Route::get('verify-email', EmailVerificationPromptController::class)
- ->name('verification.notice');
+ Route::get('verify-email', EmailVerificationPromptController::class)
+ ->name('verification.notice');
- Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
- ->middleware(['signed', 'throttle:6,1'])
- ->name('verification.verify');
+ Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
+ ->middleware(['signed', 'throttle:6,1'])
+ ->name('verification.verify');
- Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
- ->middleware('throttle:6,1')
- ->name('verification.send');
+ Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
+ ->middleware('throttle:6,1')
+ ->name('verification.send');
- Route::put('password', [PasswordController::class, 'update'])->name('password.update');
+ Route::put('password', [PasswordController::class, 'update'])->name('password.update');
- Route::delete('socialite/{provider}', [SocialiteController::class, 'disconnect'])
- ->name('auth.socialite.disconnect');
+ Route::delete('socialite/{provider}', [SocialiteController::class, 'disconnect'])
+ ->name('auth.socialite.disconnect');
- Route::post('impersonate/{userId}', ReimpersonateController::class)
- ->name('auth.impersonate.reimpersonate');
- });
+ Route::post('impersonate/{userId}', ReimpersonateController::class)
+ ->name('auth.impersonate.reimpersonate');
+ });
+
+ /**
+ * Socialite Routes
+ *
+ * These routes are placed outside the auth/guest middleware groups because:
+ * - Guests can use them for social login/registration
+ * - Authenticated users can use them to connect additional social providers
+ */
+ Route::get('socialite/{provider}', [SocialiteController::class, 'redirect'])
+ ->name('auth.socialite.redirect');
- /**
- * Socialite Routes
- *
- * These routes are placed outside the auth/guest middleware groups because:
- * - Guests can use them for social login/registration
- * - Authenticated users can use them to connect additional social providers
- */
- Route::get('socialite/{provider}', [SocialiteController::class, 'redirect'])
- ->name('auth.socialite.redirect');
-
- Route::get('socialite/{provider}/callback', [SocialiteController::class, 'callback'])
- ->name('auth.socialite.callback');
-
- /**
- * Magic Link Authentication
- *
- * Placed outside guest/auth groups because the user clicking the link
- * is not yet authenticated (they are in their email client).
- */
- Route::get('magic-link/{token}', [MagicLinkController::class, 'authenticate'])
- ->middleware('throttle:10,1')
- ->name('magic-link.authenticate');
+ Route::get('socialite/{provider}/callback', [SocialiteController::class, 'callback'])
+ ->name('auth.socialite.callback');
+
+ /**
+ * Magic Link Authentication
+ *
+ * Placed outside guest/auth groups because the user clicking the link
+ * is not yet authenticated (they are in their email client).
+ */
+ Route::get('magic-link/{token}', [MagicLinkController::class, 'authenticate'])
+ ->middleware('throttle:10,1')
+ ->name('magic-link.authenticate');
+ });
});
diff --git a/app/Exceptions/AuthException.php b/src/Exceptions/AuthException.php
similarity index 100%
rename from app/Exceptions/AuthException.php
rename to src/Exceptions/AuthException.php
diff --git a/app/Exceptions/SocialiteException.php b/src/Exceptions/SocialiteException.php
similarity index 100%
rename from app/Exceptions/SocialiteException.php
rename to src/Exceptions/SocialiteException.php
diff --git a/app/Filament/AuthPlugin.php b/src/Filament/AuthPlugin.php
similarity index 100%
rename from app/Filament/AuthPlugin.php
rename to src/Filament/AuthPlugin.php
diff --git a/app/Filament/Resources/Users/Pages/CreateUser.php b/src/Filament/Resources/Users/Pages/CreateUser.php
similarity index 100%
rename from app/Filament/Resources/Users/Pages/CreateUser.php
rename to src/Filament/Resources/Users/Pages/CreateUser.php
diff --git a/app/Filament/Resources/Users/Pages/EditUser.php b/src/Filament/Resources/Users/Pages/EditUser.php
similarity index 100%
rename from app/Filament/Resources/Users/Pages/EditUser.php
rename to src/Filament/Resources/Users/Pages/EditUser.php
diff --git a/app/Filament/Resources/Users/Pages/ListUsers.php b/src/Filament/Resources/Users/Pages/ListUsers.php
similarity index 100%
rename from app/Filament/Resources/Users/Pages/ListUsers.php
rename to src/Filament/Resources/Users/Pages/ListUsers.php
diff --git a/app/Filament/Resources/Users/Pages/ViewUser.php b/src/Filament/Resources/Users/Pages/ViewUser.php
similarity index 100%
rename from app/Filament/Resources/Users/Pages/ViewUser.php
rename to src/Filament/Resources/Users/Pages/ViewUser.php
diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/src/Filament/Resources/Users/Schemas/UserForm.php
similarity index 100%
rename from app/Filament/Resources/Users/Schemas/UserForm.php
rename to src/Filament/Resources/Users/Schemas/UserForm.php
diff --git a/app/Filament/Resources/Users/Schemas/UserInfolist.php b/src/Filament/Resources/Users/Schemas/UserInfolist.php
similarity index 100%
rename from app/Filament/Resources/Users/Schemas/UserInfolist.php
rename to src/Filament/Resources/Users/Schemas/UserInfolist.php
diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/src/Filament/Resources/Users/Tables/UsersTable.php
similarity index 100%
rename from app/Filament/Resources/Users/Tables/UsersTable.php
rename to src/Filament/Resources/Users/Tables/UsersTable.php
diff --git a/app/Filament/Resources/Users/UserResource.php b/src/Filament/Resources/Users/UserResource.php
similarity index 100%
rename from app/Filament/Resources/Users/UserResource.php
rename to src/Filament/Resources/Users/UserResource.php
diff --git a/app/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php
similarity index 100%
rename from app/Http/Controllers/Controller.php
rename to src/Http/Controllers/Controller.php
diff --git a/app/Http/Controllers/EmailVerificationNotificationController.php b/src/Http/Controllers/EmailVerificationNotificationController.php
similarity index 100%
rename from app/Http/Controllers/EmailVerificationNotificationController.php
rename to src/Http/Controllers/EmailVerificationNotificationController.php
diff --git a/app/Http/Controllers/EmailVerificationPromptController.php b/src/Http/Controllers/EmailVerificationPromptController.php
similarity index 100%
rename from app/Http/Controllers/EmailVerificationPromptController.php
rename to src/Http/Controllers/EmailVerificationPromptController.php
diff --git a/app/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php
similarity index 100%
rename from app/Http/Controllers/ForgotPasswordController.php
rename to src/Http/Controllers/ForgotPasswordController.php
diff --git a/app/Http/Controllers/LoginController.php b/src/Http/Controllers/LoginController.php
similarity index 100%
rename from app/Http/Controllers/LoginController.php
rename to src/Http/Controllers/LoginController.php
diff --git a/app/Http/Controllers/MagicLinkController.php b/src/Http/Controllers/MagicLinkController.php
similarity index 100%
rename from app/Http/Controllers/MagicLinkController.php
rename to src/Http/Controllers/MagicLinkController.php
diff --git a/app/Http/Controllers/PasswordController.php b/src/Http/Controllers/PasswordController.php
similarity index 100%
rename from app/Http/Controllers/PasswordController.php
rename to src/Http/Controllers/PasswordController.php
diff --git a/app/Http/Controllers/RegisterController.php b/src/Http/Controllers/RegisterController.php
similarity index 100%
rename from app/Http/Controllers/RegisterController.php
rename to src/Http/Controllers/RegisterController.php
diff --git a/app/Http/Controllers/ReimpersonateController.php b/src/Http/Controllers/ReimpersonateController.php
similarity index 100%
rename from app/Http/Controllers/ReimpersonateController.php
rename to src/Http/Controllers/ReimpersonateController.php
diff --git a/app/Http/Controllers/ResetPasswordController.php b/src/Http/Controllers/ResetPasswordController.php
similarity index 100%
rename from app/Http/Controllers/ResetPasswordController.php
rename to src/Http/Controllers/ResetPasswordController.php
diff --git a/app/Http/Controllers/SocialiteController.php b/src/Http/Controllers/SocialiteController.php
similarity index 100%
rename from app/Http/Controllers/SocialiteController.php
rename to src/Http/Controllers/SocialiteController.php
diff --git a/app/Http/Controllers/VerifyEmailController.php b/src/Http/Controllers/VerifyEmailController.php
similarity index 100%
rename from app/Http/Controllers/VerifyEmailController.php
rename to src/Http/Controllers/VerifyEmailController.php
diff --git a/app/Http/Middleware/EnsureMagicLinkEnabled.php b/src/Http/Middleware/EnsureMagicLinkEnabled.php
similarity index 100%
rename from app/Http/Middleware/EnsureMagicLinkEnabled.php
rename to src/Http/Middleware/EnsureMagicLinkEnabled.php
diff --git a/app/Http/Requests/LoginRequest.php b/src/Http/Requests/LoginRequest.php
similarity index 100%
rename from app/Http/Requests/LoginRequest.php
rename to src/Http/Requests/LoginRequest.php
diff --git a/app/Http/Requests/RegisterRequest.php b/src/Http/Requests/RegisterRequest.php
similarity index 100%
rename from app/Http/Requests/RegisterRequest.php
rename to src/Http/Requests/RegisterRequest.php
diff --git a/app/Listeners/AssignUserRole.php b/src/Listeners/AssignUserRole.php
similarity index 100%
rename from app/Listeners/AssignUserRole.php
rename to src/Listeners/AssignUserRole.php
diff --git a/app/Listeners/Impersonation.php b/src/Listeners/Impersonation.php
similarity index 100%
rename from app/Listeners/Impersonation.php
rename to src/Listeners/Impersonation.php
diff --git a/app/Listeners/SendWelcomeNotification.php b/src/Listeners/SendWelcomeNotification.php
similarity index 100%
rename from app/Listeners/SendWelcomeNotification.php
rename to src/Listeners/SendWelcomeNotification.php
diff --git a/app/Listeners/UpdateUserLastLogin.php b/src/Listeners/UpdateUserLastLogin.php
similarity index 100%
rename from app/Listeners/UpdateUserLastLogin.php
rename to src/Listeners/UpdateUserLastLogin.php
diff --git a/app/Models/MagicLinkToken.php b/src/Models/MagicLinkToken.php
similarity index 100%
rename from app/Models/MagicLinkToken.php
rename to src/Models/MagicLinkToken.php
diff --git a/app/Models/SocialAccount.php b/src/Models/SocialAccount.php
similarity index 100%
rename from app/Models/SocialAccount.php
rename to src/Models/SocialAccount.php
diff --git a/app/Notifications/MagicLinkNotification.php b/src/Notifications/MagicLinkNotification.php
similarity index 100%
rename from app/Notifications/MagicLinkNotification.php
rename to src/Notifications/MagicLinkNotification.php
diff --git a/app/Notifications/WelcomeNotification.php b/src/Notifications/WelcomeNotification.php
similarity index 100%
rename from app/Notifications/WelcomeNotification.php
rename to src/Notifications/WelcomeNotification.php
diff --git a/app/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php
similarity index 91%
rename from app/Providers/AuthServiceProvider.php
rename to src/Providers/AuthServiceProvider.php
index 6d563c9..54f703b 100644
--- a/app/Providers/AuthServiceProvider.php
+++ b/src/Providers/AuthServiceProvider.php
@@ -12,19 +12,6 @@
class AuthServiceProvider extends ModuleServiceProvider
{
- protected string $name = 'Auth';
-
- protected string $nameLower = 'auth';
-
- protected array $providers = [
- RouteServiceProvider::class,
- ];
-
- public function boot(): void
- {
- parent::boot();
- }
-
/**
* Share Inertia data globally.
*/
@@ -130,6 +117,6 @@ protected function registerConfig(): void
{
parent::registerConfig();
- $this->mergeConfigFrom(module_path($this->name, 'config/services.php'), 'services');
+ $this->mergeConfigFrom(module_path($this->moduleName(), 'config/services.php'), 'services');
}
}
diff --git a/app/Services/SocialiteService.php b/src/Services/SocialiteService.php
similarity index 100%
rename from app/Services/SocialiteService.php
rename to src/Services/SocialiteService.php
diff --git a/app/Traits/Sociable.php b/src/Traits/Sociable.php
similarity index 100%
rename from app/Traits/Sociable.php
rename to src/Traits/Sociable.php
From 4b04ef061ee1836a5342b366dbdc35bd43c1520c Mon Sep 17 00:00:00 2001
From: roble
Date: Sun, 26 Apr 2026 16:07:28 +0100
Subject: [PATCH 02/12] fix: update translation keys to use namespaced format
for auth messages
---
src/Exceptions/AuthException.php | 4 ++--
src/Exceptions/SocialiteException.php | 14 +++++++-------
.../EmailVerificationNotificationController.php | 2 +-
src/Http/Controllers/ForgotPasswordController.php | 2 +-
src/Http/Controllers/LoginController.php | 2 +-
src/Http/Controllers/MagicLinkController.php | 8 ++++----
src/Http/Controllers/RegisterController.php | 2 +-
src/Http/Controllers/ResetPasswordController.php | 4 ++--
src/Http/Controllers/SocialiteController.php | 10 +++++-----
9 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/src/Exceptions/AuthException.php b/src/Exceptions/AuthException.php
index 730ea8b..85b32f0 100644
--- a/src/Exceptions/AuthException.php
+++ b/src/Exceptions/AuthException.php
@@ -30,7 +30,7 @@ public function __construct(string $message = 'Authentication error.')
*/
public static function invalidCredentials(): self
{
- return new self(trans('auth.failed'));
+ return new self(trans('auth::auth.failed'));
}
/**
@@ -41,7 +41,7 @@ public static function invalidCredentials(): self
*/
public static function throttle(int $seconds = 60): self
{
- $message = trans('auth.throttle', [
+ $message = trans('auth::auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]);
diff --git a/src/Exceptions/SocialiteException.php b/src/Exceptions/SocialiteException.php
index 3d7a1b2..097c83a 100644
--- a/src/Exceptions/SocialiteException.php
+++ b/src/Exceptions/SocialiteException.php
@@ -8,36 +8,36 @@ class SocialiteException extends Exception
{
public static function invalidSocialUser(): self
{
- return new self(trans('socialite.invalid_user'));
+ return new self(trans('auth::socialite.invalid_user'));
}
public static function cannotDisconnectOnlyMethod(): self
{
- return new self(trans('socialite.cannot_disconnect_only_method'));
+ return new self(trans('auth::socialite.cannot_disconnect_only_method'));
}
public static function authenticationFailed(): self
{
- return new self(trans('socialite.error'));
+ return new self(trans('auth::socialite.error'));
}
public static function providerNotConnected(string $provider): self
{
- return new self(trans('socialite.not_connected', ['Provider' => $provider]));
+ return new self(trans('auth::socialite.not_connected', ['Provider' => $provider]));
}
public static function missingSocialAccountsRelation(): self
{
- return new self(trans('socialite.missing_social_accounts_relation'));
+ return new self(trans('auth::socialite.missing_social_accounts_relation'));
}
public static function accountAlreadyLinked(string $provider): self
{
- return new self(trans('socialite.account_already_linked', ['provider' => ucfirst($provider)]));
+ return new self(trans('auth::socialite.account_already_linked', ['provider' => ucfirst($provider)]));
}
public static function unsupportedProvider(string $provider): self
{
- return new self(trans('socialite.unsupported_provider', ['provider' => ucfirst($provider)]));
+ return new self(trans('auth::socialite.unsupported_provider', ['provider' => ucfirst($provider)]));
}
}
diff --git a/src/Http/Controllers/EmailVerificationNotificationController.php b/src/Http/Controllers/EmailVerificationNotificationController.php
index d2cc516..22f9ce6 100644
--- a/src/Http/Controllers/EmailVerificationNotificationController.php
+++ b/src/Http/Controllers/EmailVerificationNotificationController.php
@@ -18,6 +18,6 @@ public function store(Request $request): RedirectResponse
$request->user()->sendEmailVerificationNotification();
- return back()->with('status', trans('auth.verification-link-sent'));
+ return back()->with('status', trans('auth::auth.verification-link-sent'));
}
}
diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php
index daf9b75..ff861c9 100644
--- a/src/Http/Controllers/ForgotPasswordController.php
+++ b/src/Http/Controllers/ForgotPasswordController.php
@@ -37,6 +37,6 @@ public function store(Request $request): RedirectResponse
$request->only('email')
);
- return back()->with('status', trans('passwords.sent'));
+ return back()->with('status', trans('auth::passwords.sent'));
}
}
diff --git a/src/Http/Controllers/LoginController.php b/src/Http/Controllers/LoginController.php
index 2618caf..c1a0017 100644
--- a/src/Http/Controllers/LoginController.php
+++ b/src/Http/Controllers/LoginController.php
@@ -41,7 +41,7 @@ public function store(LoginRequest $request)
request()->session()->regenerate();
Toast::default(
- __('auth.welcome-back', ['name' => $user->name]),
+ __('auth::auth.welcome-back', ['name' => $user->name]),
);
if ($request->session()->has('url.intended')) {
diff --git a/src/Http/Controllers/MagicLinkController.php b/src/Http/Controllers/MagicLinkController.php
index 821635a..03c6ad8 100644
--- a/src/Http/Controllers/MagicLinkController.php
+++ b/src/Http/Controllers/MagicLinkController.php
@@ -67,7 +67,7 @@ public function store(Request $request): RedirectResponse
$user->notify(new MagicLinkNotification($url));
}
- return back()->with('status', __('auth.magic-link-sent'));
+ return back()->with('status', __('auth::auth.magic-link-sent'));
}
/**
@@ -85,21 +85,21 @@ public function authenticate(Request $request, string $token): \Symfony\Componen
->update(['used_at' => $now]);
if (! $consumed) {
- return redirect()->route('login')->with('error', __('auth.magic-link-expired'));
+ return redirect()->route('login')->with('error', __('auth::auth.magic-link-expired'));
}
$record = MagicLinkToken::where('token', $hashed)->with('user')->first();
$user = $record?->user;
if ($user === null) {
- return redirect()->route('login')->with('error', __('auth.magic-link-expired'));
+ return redirect()->route('login')->with('error', __('auth::auth.magic-link-expired'));
}
Auth::login($user);
$request->session()->regenerate();
- Toast::default(__('auth.welcome-back', ['name' => $user->name]));
+ Toast::default(__('auth::auth.welcome-back', ['name' => $user->name]));
$intended = $request->query('intended');
diff --git a/src/Http/Controllers/RegisterController.php b/src/Http/Controllers/RegisterController.php
index 5b28533..bc945f5 100644
--- a/src/Http/Controllers/RegisterController.php
+++ b/src/Http/Controllers/RegisterController.php
@@ -37,7 +37,7 @@ public function store(RegisterRequest $request): RedirectResponse
Auth::login($user);
Toast::default(
- __('auth.welcome', ['name' => $user->name]),
+ __('auth::auth.welcome', ['name' => $user->name]),
);
return redirect()->intended(route('dashboard'));
diff --git a/src/Http/Controllers/ResetPasswordController.php b/src/Http/Controllers/ResetPasswordController.php
index 5a702dc..220ed6d 100644
--- a/src/Http/Controllers/ResetPasswordController.php
+++ b/src/Http/Controllers/ResetPasswordController.php
@@ -59,9 +59,9 @@ function ($user) use ($request) {
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PASSWORD_RESET) {
- return redirect()->route('login')->with('status', trans($status));
+ return redirect()->route('login')->with('status', trans('auth::'.$status));
}
- return back()->with('error', trans($status));
+ return back()->with('error', trans('auth::'.$status));
}
}
diff --git a/src/Http/Controllers/SocialiteController.php b/src/Http/Controllers/SocialiteController.php
index d47c0f6..ea1e159 100644
--- a/src/Http/Controllers/SocialiteController.php
+++ b/src/Http/Controllers/SocialiteController.php
@@ -32,7 +32,7 @@ public function callback(string $provider): RedirectResponse
]);
if ($validator->fails()) {
- return back()->with('error', trans('socialite.error'));
+ return back()->with('error', trans('auth::socialite.error'));
}
// Check if user is already authenticated (account linking flow)
@@ -41,11 +41,11 @@ public function callback(string $provider): RedirectResponse
/** @var User $socialUser */
$socialUser = Socialite::driver($provider)->user();
$this->socialiteService->linkAccountToUser(Auth::user(), $provider, $socialUser);
- Toast::success(trans('socialite.account_connected', ['provider' => ucfirst($provider)]));
+ Toast::success(trans('auth::socialite.account_connected', ['provider' => ucfirst($provider)]));
} catch (SocialiteException $e) {
Toast::error($e->getMessage());
} catch (\Exception $e) {
- Toast::error(trans('socialite.error'));
+ Toast::error(trans('auth::socialite.error'));
report($e);
} finally {
return back();
@@ -60,7 +60,7 @@ public function callback(string $provider): RedirectResponse
request()->session()->regenerate();
Toast::default(
- __($user->wasRecentlyCreated ? 'auth.welcome' : 'auth.welcome-back', [
+ __($user->wasRecentlyCreated ? 'auth::auth.welcome' : 'auth::auth.welcome-back', [
'name' => $user->name,
]),
);
@@ -79,7 +79,7 @@ public function disconnect(string $provider): RedirectResponse
try {
$this->socialiteService->disconnectProvider($user, $provider);
- Toast::success(trans('socialite.account_disconnected', ['provider' => $provider]));
+ Toast::success(trans('auth::socialite.account_disconnected', ['provider' => $provider]));
} catch (SocialiteException $e) {
Toast::error($e->getMessage());
}
From c2570ac242dd7bedbfffdb873e943d2f6024de2e Mon Sep 17 00:00:00 2001
From: roble
Date: Tue, 28 Apr 2026 20:47:02 +0100
Subject: [PATCH 03/12] feat(auth): add getNavigationGroupSort method to
AuthPlugin
---
src/Filament/AuthPlugin.php | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/Filament/AuthPlugin.php b/src/Filament/AuthPlugin.php
index 387fa3c..4a71c3c 100644
--- a/src/Filament/AuthPlugin.php
+++ b/src/Filament/AuthPlugin.php
@@ -23,6 +23,11 @@ public function getId(): string
return 'auth';
}
+ public static function getNavigationGroupSort(): int
+ {
+ return 2;
+ }
+
public function boot(Panel $panel): void
{
$panel->navigationGroups([
From edb5c4590733bc1a7f3b703dffbcb8f5efdb4dad Mon Sep 17 00:00:00 2001
From: roble
Date: Wed, 29 Apr 2026 21:37:16 +0100
Subject: [PATCH 04/12] feat(auth): add db:seed task to seed the Auth module
database
---
Taskfile.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/Taskfile.yml b/Taskfile.yml
index 3e54da6..5f75072 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -12,6 +12,12 @@ tasks:
cmd: npx playwright test --project="@auth*" {{.CLI_ARGS}}
interactive: true
+ # ── Database ──────────────────────────────────────────────────
+
+ db:seed:
+ desc: Seed the Auth module database
+ cmd: php artisan modules:seed --module=auth
+
# ── Code Generation ────────────────────────────────────────────
types:generate:
From 9633430f341c6243e5ce0b6978496967a3606d88 Mon Sep 17 00:00:00 2001
From: roble
Date: Mon, 11 May 2026 21:15:02 +0100
Subject: [PATCH 05/12] feat(auth): restructure authentication module with new
Vue components and layout
---
resources/js/app.ts | 56 +------------------
resources/js/vue/app.ts | 54 ++++++++++++++++++
.../components/ImpersonationAlert.vue | 0
.../components/SocialiteProviders.vue | 0
.../js/{ => vue}/layouts/AuthCardLayout.vue | 0
.../js/{ => vue}/pages/ForgotPassword.vue | 0
resources/js/{ => vue}/pages/Login.vue | 0
resources/js/{ => vue}/pages/MagicLink.vue | 0
resources/js/{ => vue}/pages/Register.vue | 0
.../js/{ => vue}/pages/ResetPassword.vue | 0
resources/js/{ => vue}/pages/VerifyEmail.vue | 0
vite.config.js | 17 +-----
12 files changed, 57 insertions(+), 70 deletions(-)
create mode 100644 resources/js/vue/app.ts
rename resources/js/{ => vue}/components/ImpersonationAlert.vue (100%)
rename resources/js/{ => vue}/components/SocialiteProviders.vue (100%)
rename resources/js/{ => vue}/layouts/AuthCardLayout.vue (100%)
rename resources/js/{ => vue}/pages/ForgotPassword.vue (100%)
rename resources/js/{ => vue}/pages/Login.vue (100%)
rename resources/js/{ => vue}/pages/MagicLink.vue (100%)
rename resources/js/{ => vue}/pages/Register.vue (100%)
rename resources/js/{ => vue}/pages/ResetPassword.vue (100%)
rename resources/js/{ => vue}/pages/VerifyEmail.vue (100%)
diff --git a/resources/js/app.ts b/resources/js/app.ts
index ade9b9f..3043f15 100644
--- a/resources/js/app.ts
+++ b/resources/js/app.ts
@@ -1,55 +1 @@
-import { useDialog } from '@/composables/useDialog';
-import { registerGlobalComponent } from '@/lib/globalComponents';
-import { registerAction, registerIcon } from '@/lib/navigation';
-import { router } from '@inertiajs/vue3';
-import { trans } from 'laravel-vue-i18n';
-import { LogOut } from 'lucide-vue-next';
-import IconLogOut from '~icons/lucide/log-out';
-import ImpersonationAlert from './components/ImpersonationAlert.vue';
-
-import '../css/style.css';
-
-/**
- * Auth module setup
- * Called during app initialization before mounting
- */
-export function setup() {
- registerIcon('logout', IconLogOut);
- registerAuthActions();
- registerGlobalComponent('top', ImpersonationAlert);
-}
-
-/**
- * Register auth-related navigation actions
- */
-function registerAuthActions() {
- // Logout action
- registerAction('logout', async (event: MouseEvent) => {
- event.preventDefault();
-
- const { confirm } = useDialog();
- if (
- await confirm({
- title: trans('Log out'),
- description: trans(
- 'Are you sure you want to log out? You will need to sign in again.',
- ),
- confirmLabel: trans('Log out'),
- cancelLabel: trans('Cancel'),
- variant: 'destructive',
- icon: LogOut,
- align: 'left',
- })
- ) {
- router.post(route('logout'));
- }
- });
-}
-
-/**
- * Auth module after mount logic
- * Called after the app has been mounted
- */
-export function afterMount() {
- console.debug('Auth module after mount logic executed');
-}
+export * from './vue/app';
diff --git a/resources/js/vue/app.ts b/resources/js/vue/app.ts
new file mode 100644
index 0000000..d692f25
--- /dev/null
+++ b/resources/js/vue/app.ts
@@ -0,0 +1,54 @@
+import '@modules/auth/resources/css/style.css';
+import { useDialog } from '@/composables/useDialog';
+import { registerGlobalComponent } from '@/lib/globalComponents';
+import { registerAction, registerIcon } from '@/lib/navigation';
+import { router } from '@inertiajs/vue3';
+import { trans } from 'laravel-vue-i18n';
+import { LogOut } from 'lucide-vue-next';
+import IconLogOut from '~icons/lucide/log-out';
+import ImpersonationAlert from './components/ImpersonationAlert.vue';
+
+/**
+ * Auth module setup
+ * Called during app initialization before mounting
+ */
+export function setup() {
+ registerIcon('logout', IconLogOut);
+ registerAuthActions();
+ registerGlobalComponent('top', ImpersonationAlert);
+}
+
+/**
+ * Register auth-related navigation actions
+ */
+function registerAuthActions() {
+ // Logout action
+ registerAction('logout', async (event: MouseEvent) => {
+ event.preventDefault();
+
+ const { confirm } = useDialog();
+ if (
+ await confirm({
+ title: trans('Log out'),
+ description: trans(
+ 'Are you sure you want to log out? You will need to sign in again.',
+ ),
+ confirmLabel: trans('Log out'),
+ cancelLabel: trans('Cancel'),
+ variant: 'destructive',
+ icon: LogOut,
+ align: 'left',
+ })
+ ) {
+ router.post(route('logout'));
+ }
+ });
+}
+
+/**
+ * Auth module after mount logic
+ * Called after the app has been mounted
+ */
+export function afterMount() {
+ console.debug('Auth module after mount logic executed');
+}
diff --git a/resources/js/components/ImpersonationAlert.vue b/resources/js/vue/components/ImpersonationAlert.vue
similarity index 100%
rename from resources/js/components/ImpersonationAlert.vue
rename to resources/js/vue/components/ImpersonationAlert.vue
diff --git a/resources/js/components/SocialiteProviders.vue b/resources/js/vue/components/SocialiteProviders.vue
similarity index 100%
rename from resources/js/components/SocialiteProviders.vue
rename to resources/js/vue/components/SocialiteProviders.vue
diff --git a/resources/js/layouts/AuthCardLayout.vue b/resources/js/vue/layouts/AuthCardLayout.vue
similarity index 100%
rename from resources/js/layouts/AuthCardLayout.vue
rename to resources/js/vue/layouts/AuthCardLayout.vue
diff --git a/resources/js/pages/ForgotPassword.vue b/resources/js/vue/pages/ForgotPassword.vue
similarity index 100%
rename from resources/js/pages/ForgotPassword.vue
rename to resources/js/vue/pages/ForgotPassword.vue
diff --git a/resources/js/pages/Login.vue b/resources/js/vue/pages/Login.vue
similarity index 100%
rename from resources/js/pages/Login.vue
rename to resources/js/vue/pages/Login.vue
diff --git a/resources/js/pages/MagicLink.vue b/resources/js/vue/pages/MagicLink.vue
similarity index 100%
rename from resources/js/pages/MagicLink.vue
rename to resources/js/vue/pages/MagicLink.vue
diff --git a/resources/js/pages/Register.vue b/resources/js/vue/pages/Register.vue
similarity index 100%
rename from resources/js/pages/Register.vue
rename to resources/js/vue/pages/Register.vue
diff --git a/resources/js/pages/ResetPassword.vue b/resources/js/vue/pages/ResetPassword.vue
similarity index 100%
rename from resources/js/pages/ResetPassword.vue
rename to resources/js/vue/pages/ResetPassword.vue
diff --git a/resources/js/pages/VerifyEmail.vue b/resources/js/vue/pages/VerifyEmail.vue
similarity index 100%
rename from resources/js/pages/VerifyEmail.vue
rename to resources/js/vue/pages/VerifyEmail.vue
diff --git a/vite.config.js b/vite.config.js
index e100344..589d91b 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,15 +1,2 @@
-/**
- * Module Asset Configuration
- *
- * Define CSS, JS, and TS files to be compiled when this module is enabled.
- * Paths are relative to this module's resources/assets/ directory.
- * Vue components in resources/js/pages/ are discovered automatically.
- *
- * Example:
- * export const paths = [
- * 'css/app.css', // Becomes modules/ModuleName/resources/assets/css/app.css
- * 'js/app.ts', // Becomes modules/ModuleName/resources/assets/js/app.ts
- * 'js/custom-feature.js', // Becomes modules/ModuleName/resources/assets/js/custom-feature.js
- * ];
- */
-export const paths = ['css/style.css', 'js/app.ts'];
+// TODO: figure out if we need to export anything here for the auth module's frontend assets. If not, we can just leave this file empty or delete it.
+export default {};
From 6897477130c80e9dcbb6cafdf0fb530698b39ada Mon Sep 17 00:00:00 2001
From: roble
Date: Sun, 17 May 2026 17:39:03 +0100
Subject: [PATCH 06/12] feat: implement react new authentication pages and
components including login, registration, password reset, and email
verification
---
resources/js/react/app.tsx | 38 ++++
.../react/components/ImpersonationAlert.tsx | 173 ++++++++++++++++++
.../react/components/SocialiteProviders.tsx | 50 +++++
resources/js/react/layouts/AuthCardLayout.tsx | 55 ++++++
resources/js/react/pages/ForgotPassword.tsx | 60 ++++++
resources/js/react/pages/Login.tsx | 137 ++++++++++++++
resources/js/react/pages/MagicLink.tsx | 55 ++++++
resources/js/react/pages/Register.tsx | 109 +++++++++++
resources/js/react/pages/ResetPassword.tsx | 87 +++++++++
resources/js/react/pages/VerifyEmail.tsx | 41 +++++
10 files changed, 805 insertions(+)
create mode 100644 resources/js/react/app.tsx
create mode 100644 resources/js/react/components/ImpersonationAlert.tsx
create mode 100644 resources/js/react/components/SocialiteProviders.tsx
create mode 100644 resources/js/react/layouts/AuthCardLayout.tsx
create mode 100644 resources/js/react/pages/ForgotPassword.tsx
create mode 100644 resources/js/react/pages/Login.tsx
create mode 100644 resources/js/react/pages/MagicLink.tsx
create mode 100644 resources/js/react/pages/Register.tsx
create mode 100644 resources/js/react/pages/ResetPassword.tsx
create mode 100644 resources/js/react/pages/VerifyEmail.tsx
diff --git a/resources/js/react/app.tsx b/resources/js/react/app.tsx
new file mode 100644
index 0000000..e4030f7
--- /dev/null
+++ b/resources/js/react/app.tsx
@@ -0,0 +1,38 @@
+import '@modules/auth/resources/css/style.css';
+import { registerGlobalComponent } from '@/lib/globalComponents';
+import { registerAction, registerIcon } from '@/lib/navigation';
+import { confirm } from '@/hooks/useDialog';
+import { router } from '@inertiajs/react';
+import { LogOut } from 'lucide-react';
+import IconLogOut from '~icons/lucide/log-out';
+import ImpersonationAlert from './components/ImpersonationAlert';
+
+export function setup() {
+ registerIcon('logout', IconLogOut);
+ registerAuthActions();
+ registerGlobalComponent('top', ImpersonationAlert);
+}
+
+function registerAuthActions() {
+ registerAction('logout', async (event: MouseEvent) => {
+ event.preventDefault();
+
+ const confirmed = await confirm({
+ title: 'Log out',
+ description: 'Are you sure you want to log out? You will need to sign in again.',
+ confirmLabel: 'Log out',
+ cancelLabel: 'Cancel',
+ variant: 'destructive',
+ icon: LogOut,
+ align: 'left',
+ });
+
+ if (confirmed) {
+ router.post(route('logout'));
+ }
+ });
+}
+
+export function afterMount() {
+ console.debug('Auth module after mount logic executed');
+}
diff --git a/resources/js/react/components/ImpersonationAlert.tsx b/resources/js/react/components/ImpersonationAlert.tsx
new file mode 100644
index 0000000..a18024c
--- /dev/null
+++ b/resources/js/react/components/ImpersonationAlert.tsx
@@ -0,0 +1,173 @@
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { cn } from '@/lib/utils';
+import type { User } from '@/types';
+import { router, usePage } from '@inertiajs/react';
+import { Drama, History, X } from 'lucide-react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useT } from '@/i18n';
+
+interface Impersonation {
+ user: User;
+ route: string;
+ label: string;
+ recent: User[];
+}
+
+function getUserInitials(name: string): string {
+ return name
+ .split(' ')
+ .map((word) => word.charAt(0).toUpperCase())
+ .slice(0, 2)
+ .join('');
+}
+
+function getRoleBadgeClasses(role: string): string {
+ return cn(
+ 'shrink-0 rounded-xl px-1 py-0.5 text-[9px] text-white uppercase',
+ role === 'admin' ? 'bg-red-800' : 'bg-cyan-700',
+ );
+}
+
+export default function ImpersonationAlert() {
+ const t = useT();
+ const page = usePage();
+ const impersonation = (page.props?.impersonation as Impersonation) || null;
+ const [isExpanded, setIsExpanded] = useState(false);
+ const alertRef = useRef(null);
+
+ const collapse = useCallback(() => setIsExpanded(false), []);
+
+ useEffect(() => {
+ if (!isExpanded) return;
+
+ function handleClickOutside(e: MouseEvent) {
+ if (alertRef.current && !alertRef.current.contains(e.target as Node)) {
+ collapse();
+ }
+ }
+
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') collapse();
+ }
+
+ document.addEventListener('mousedown', handleClickOutside);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isExpanded, collapse]);
+
+ const reimpersonate = (userId: number) => {
+ router.post(
+ route('auth.impersonate.reimpersonate', { userId }),
+ {},
+ { preserveScroll: true, onSuccess: collapse },
+ );
+ };
+
+ if (!impersonation) return null;
+
+ return (
+
+ {!isExpanded ? (
+
+ ) : (
+
+
+
+
+
+
+ {getUserInitials(impersonation.user.name)}
+
+
+
+
+
+ {impersonation.user.name}
+
+ {impersonation.user.role && (
+
+ {impersonation.user.role}
+
+ )}
+
+
{impersonation.user.email}
+
+
+
+ {impersonation.label}
+
+
+ {impersonation.recent?.length > 0 && (
+
+
+
+ {t('Recent impersonated users')}
+
+
+ {impersonation.recent.map((user) => (
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/resources/js/react/components/SocialiteProviders.tsx b/resources/js/react/components/SocialiteProviders.tsx
new file mode 100644
index 0000000..5288ab2
--- /dev/null
+++ b/resources/js/react/components/SocialiteProviders.tsx
@@ -0,0 +1,50 @@
+import { Button } from '@/components/ui/button';
+import { usePage } from '@inertiajs/react';
+import { useT } from '@/i18n';
+import IconGithub from '~icons/simple-icons/github';
+import IconGoogle from '~icons/simple-icons/google';
+
+type Provider = { name: string; icon: React.ComponentType<{ className?: string }> };
+
+const providers: Provider[] = [
+ { name: 'google', icon: IconGoogle },
+ { name: 'github', icon: IconGithub },
+];
+
+export default function SocialiteProviders() {
+ const t = useT();
+ const page = usePage();
+ const lastUsed = (page.props.auth as any)?.last_social_provider as string | undefined;
+
+ if (!route().has('auth.socialite.redirect') || !providers.length) {
+ return null;
+ }
+
+ return (
+
+ {providers.map(({ name, icon: Icon }) => (
+
+ ))}
+
+
+ {t('Or continue with email')}
+
+
+
+ );
+}
diff --git a/resources/js/react/layouts/AuthCardLayout.tsx b/resources/js/react/layouts/AuthCardLayout.tsx
new file mode 100644
index 0000000..76d935e
--- /dev/null
+++ b/resources/js/react/layouts/AuthCardLayout.tsx
@@ -0,0 +1,55 @@
+import AlertMessage from '@/components/AlertMessage';
+import AppLogo from '@/components/AppLogo';
+import Footer from '@/components/Footer';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Head, Link, usePage } from '@inertiajs/react';
+import type { ReactNode } from 'react';
+
+interface AuthCardLayoutProps {
+ title?: string;
+ description?: string;
+ cardClass?: string;
+ children: ReactNode;
+ outside?: ReactNode;
+}
+
+export default function AuthCardLayout({ title, description, cardClass, children, outside }: AuthCardLayoutProps) {
+ const page = usePage();
+ const status = page.props.status as string | undefined;
+ const error = page.props.error as string | undefined;
+
+ return (
+
+
+
+
+
+
+ {title}
+ {description}
+
+
+ {(status || error) && (
+
+ )}
+ {children}
+
+
+
+ {outside}
+
+
+
+ );
+}
diff --git a/resources/js/react/pages/ForgotPassword.tsx b/resources/js/react/pages/ForgotPassword.tsx
new file mode 100644
index 0000000..0fa3bf7
--- /dev/null
+++ b/resources/js/react/pages/ForgotPassword.tsx
@@ -0,0 +1,60 @@
+import { Button } from '@/components/ui/button';
+import { Field, FieldError } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useT } from '@/i18n';
+import { Link, useForm, usePage } from '@inertiajs/react';
+import AuthCardLayout from '../layouts/AuthCardLayout';
+
+export default function ForgotPassword() {
+ const t = useT();
+ const page = usePage();
+ const email = page.props.email ? String(page.props.email) : '';
+
+ const { data, setData, post, processing, errors } = useForm({ email });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ post(route('password.email'), { preserveScroll: true });
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/resources/js/react/pages/Login.tsx b/resources/js/react/pages/Login.tsx
new file mode 100644
index 0000000..413d9e7
--- /dev/null
+++ b/resources/js/react/pages/Login.tsx
@@ -0,0 +1,137 @@
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Field, FieldError, FieldLabel } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useT } from '@/i18n';
+import { Link, useForm, usePage } from '@inertiajs/react';
+import { Eye, EyeOff } from 'lucide-react';
+import { useState } from 'react';
+import SocialiteProviders from '../components/SocialiteProviders';
+import AuthCardLayout from '../layouts/AuthCardLayout';
+
+export default function Login() {
+ const t = useT();
+ const page = usePage();
+ const [email, setEmail] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+
+ const { data, setData, post, processing, errors } = useForm({
+ email: '',
+ password: '',
+ remember: false,
+ });
+
+ const forgotUrl = route('password.request', { email });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ post(route('login'), { preserveScroll: true });
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/resources/js/react/pages/MagicLink.tsx b/resources/js/react/pages/MagicLink.tsx
new file mode 100644
index 0000000..0b63ef3
--- /dev/null
+++ b/resources/js/react/pages/MagicLink.tsx
@@ -0,0 +1,55 @@
+import { Button } from '@/components/ui/button';
+import { Field, FieldError } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useT } from '@/i18n';
+import { Link, useForm } from '@inertiajs/react';
+import AuthCardLayout from '../layouts/AuthCardLayout';
+
+export default function MagicLink() {
+ const t = useT();
+ const { data, setData, post, processing, errors } = useForm({ email: '' });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ post(route('magic-link.store'), { preserveScroll: true });
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/resources/js/react/pages/Register.tsx b/resources/js/react/pages/Register.tsx
new file mode 100644
index 0000000..8ff89bd
--- /dev/null
+++ b/resources/js/react/pages/Register.tsx
@@ -0,0 +1,109 @@
+import { Button } from '@/components/ui/button';
+import { Field, FieldError } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useT } from '@/i18n';
+import { Link, useForm } from '@inertiajs/react';
+import { Eye, EyeOff } from 'lucide-react';
+import { useState } from 'react';
+import SocialiteProviders from '../components/SocialiteProviders';
+import AuthCardLayout from '../layouts/AuthCardLayout';
+
+export default function Register() {
+ const t = useT();
+ const [showPassword, setShowPassword] = useState(false);
+ const { data, setData, post, processing, errors } = useForm({
+ name: '',
+ email: '',
+ password: '',
+ password_confirmation: '',
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ post(route('register'), { preserveScroll: true });
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/resources/js/react/pages/ResetPassword.tsx b/resources/js/react/pages/ResetPassword.tsx
new file mode 100644
index 0000000..f6c6830
--- /dev/null
+++ b/resources/js/react/pages/ResetPassword.tsx
@@ -0,0 +1,87 @@
+import { Button } from '@/components/ui/button';
+import { Field, FieldError } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useT } from '@/i18n';
+import { useForm } from '@inertiajs/react';
+import AuthCardLayout from '../layouts/AuthCardLayout';
+
+interface Props {
+ email: string;
+ token: string;
+}
+
+export default function ResetPassword({ email, token }: Props) {
+ const t = useT();
+ const { data, setData, post, processing, errors } = useForm({
+ token,
+ email,
+ password: '',
+ password_confirmation: '',
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ post(route('password.store'), { preserveScroll: true });
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/resources/js/react/pages/VerifyEmail.tsx b/resources/js/react/pages/VerifyEmail.tsx
new file mode 100644
index 0000000..a913b16
--- /dev/null
+++ b/resources/js/react/pages/VerifyEmail.tsx
@@ -0,0 +1,41 @@
+import { Button } from '@/components/ui/button';
+import { useT } from '@/i18n';
+import { Link, useForm } from '@inertiajs/react';
+import AuthCardLayout from '../layouts/AuthCardLayout';
+
+export default function VerifyEmail() {
+ const t = useT();
+ const { post, processing } = useForm({});
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ post(route('verification.send'));
+ };
+
+ return (
+
+
+
+ );
+}
From f7ebd41d554706dcfbcc3f039a59683d2862e4b9 Mon Sep 17 00:00:00 2001
From: roble
Date: Sun, 17 May 2026 20:26:47 +0100
Subject: [PATCH 07/12] feat(auth): update CLAUDE.md to include frontend
dual-framework guidelines
---
CLAUDE.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index c827c54..3eb07a7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,6 +19,14 @@ Authentication, registration, magic link (passwordless), password reset, email v
| Layout | `AuthCardLayout` — card with logo, status alerts, page transitions |
| Component | `SocialiteProviders` — Google/GitHub buttons with divider |
+## Frontend
+
+Follows the dual-framework pattern (see root `CLAUDE.md` → Architecture > Frontend).
+
+- Both `resources/js/vue/` and `resources/js/react/` exist and must stay in sync
+- `resources/js/app.ts` is a generated re-export — do not edit it directly
+- `registerIcon()`, `registerAction()`, `registerGlobalComponent()` calls in `setup()` must be mirrored in both framework implementations
+
## Routes
**Guest routes** (`/auth/*`): login (GET/POST), register (GET/POST), forgot-password (GET/POST), reset-password/{token} (GET, signed), reset-password (POST, throttle:6,1), magic-link (GET/POST, throttle:5,1)
From 487b09d5cfa096d12baef03d9239630ba5ed621a Mon Sep 17 00:00:00 2001
From: roble
Date: Sat, 23 May 2026 17:44:03 +0100
Subject: [PATCH 08/12] feat(auth): update test workflow to include support for
both Vue and React frameworks
---
.github/workflows/test.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b3ba07e..f831a24 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,3 +14,4 @@ jobs:
uses: saucebase-dev/saucebase/.github/workflows/test-module.yml@main
with:
module: auth
+ frameworks: '["vue","react"]'
From 5a1bc239860e526dbf52f0e5c341d3a409f69ca0 Mon Sep 17 00:00:00 2001
From: roble
Date: Mon, 25 May 2026 20:21:08 +0100
Subject: [PATCH 09/12] fix(auth): adjust layout styles for AuthCardLayout
component
---
resources/js/vue/layouts/AuthCardLayout.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/resources/js/vue/layouts/AuthCardLayout.vue b/resources/js/vue/layouts/AuthCardLayout.vue
index 01f0222..f79470a 100644
--- a/resources/js/vue/layouts/AuthCardLayout.vue
+++ b/resources/js/vue/layouts/AuthCardLayout.vue
@@ -21,7 +21,7 @@ defineProps<{
-
+
@@ -61,6 +61,6 @@ defineProps<{
-
+
From 32ed030f0eebe4d0cef926ce8e7eee01b64bb493 Mon Sep 17 00:00:00 2001
From: roble
Date: Wed, 27 May 2026 19:35:24 +0100
Subject: [PATCH 10/12] refactor(auth): improve code formatting and
organization across multiple components
---
resources/js/react/app.tsx | 7 +--
.../react/components/ImpersonationAlert.tsx | 48 ++++++++++++++----
.../react/components/SocialiteProviders.tsx | 23 +++++++--
resources/js/react/layouts/AuthCardLayout.tsx | 16 +++++-
resources/js/react/pages/ForgotPassword.tsx | 18 +++++--
resources/js/react/pages/Login.tsx | 50 +++++++++++++++----
resources/js/react/pages/MagicLink.tsx | 16 ++++--
resources/js/react/pages/Register.tsx | 47 ++++++++++++++---
resources/js/react/pages/ResetPassword.tsx | 45 ++++++++++++++---
resources/js/react/pages/VerifyEmail.tsx | 6 ++-
resources/js/vue/app.ts | 2 +-
11 files changed, 225 insertions(+), 53 deletions(-)
diff --git a/resources/js/react/app.tsx b/resources/js/react/app.tsx
index e4030f7..b6f14a5 100644
--- a/resources/js/react/app.tsx
+++ b/resources/js/react/app.tsx
@@ -1,8 +1,8 @@
-import '@modules/auth/resources/css/style.css';
+import { confirm } from '@/hooks/useDialog';
import { registerGlobalComponent } from '@/lib/globalComponents';
import { registerAction, registerIcon } from '@/lib/navigation';
-import { confirm } from '@/hooks/useDialog';
import { router } from '@inertiajs/react';
+import '@modules/auth/resources/css/style.css';
import { LogOut } from 'lucide-react';
import IconLogOut from '~icons/lucide/log-out';
import ImpersonationAlert from './components/ImpersonationAlert';
@@ -19,7 +19,8 @@ function registerAuthActions() {
const confirmed = await confirm({
title: 'Log out',
- description: 'Are you sure you want to log out? You will need to sign in again.',
+ description:
+ 'Are you sure you want to log out? You will need to sign in again.',
confirmLabel: 'Log out',
cancelLabel: 'Cancel',
variant: 'destructive',
diff --git a/resources/js/react/components/ImpersonationAlert.tsx b/resources/js/react/components/ImpersonationAlert.tsx
index a18024c..28a7b62 100644
--- a/resources/js/react/components/ImpersonationAlert.tsx
+++ b/resources/js/react/components/ImpersonationAlert.tsx
@@ -1,10 +1,10 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { useT } from '@/i18n';
import { cn } from '@/lib/utils';
import type { User } from '@/types';
import { router, usePage } from '@inertiajs/react';
import { Drama, History, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
-import { useT } from '@/i18n';
interface Impersonation {
user: User;
@@ -41,7 +41,10 @@ export default function ImpersonationAlert() {
if (!isExpanded) return;
function handleClickOutside(e: MouseEvent) {
- if (alertRef.current && !alertRef.current.contains(e.target as Node)) {
+ if (
+ alertRef.current &&
+ !alertRef.current.contains(e.target as Node)
+ ) {
collapse();
}
}
@@ -69,7 +72,11 @@ export default function ImpersonationAlert() {
if (!impersonation) return null;
return (
-
+
{!isExpanded ? (
-
+
{getUserInitials(impersonation.user.name)}
@@ -114,12 +127,18 @@ export default function ImpersonationAlert() {
{impersonation.user.name}
{impersonation.user.role && (
-
+
{impersonation.user.role}
)}
-
{impersonation.user.email}
+
+ {impersonation.user.email}
+
-
+
{getUserInitials(user.name)}
@@ -154,12 +176,18 @@ export default function ImpersonationAlert() {
{user.name}
{user.role && (
-
+
{user.role}
)}
- {user.email}
+
+ {user.email}
+
))}
diff --git a/resources/js/react/components/SocialiteProviders.tsx b/resources/js/react/components/SocialiteProviders.tsx
index 5288ab2..69dbb44 100644
--- a/resources/js/react/components/SocialiteProviders.tsx
+++ b/resources/js/react/components/SocialiteProviders.tsx
@@ -1,10 +1,13 @@
import { Button } from '@/components/ui/button';
-import { usePage } from '@inertiajs/react';
import { useT } from '@/i18n';
+import { usePage } from '@inertiajs/react';
import IconGithub from '~icons/simple-icons/github';
import IconGoogle from '~icons/simple-icons/google';
-type Provider = { name: string; icon: React.ComponentType<{ className?: string }> };
+type Provider = {
+ name: string;
+ icon: React.ComponentType<{ className?: string }>;
+};
const providers: Provider[] = [
{ name: 'google', icon: IconGoogle },
@@ -14,7 +17,9 @@ const providers: Provider[] = [
export default function SocialiteProviders() {
const t = useT();
const page = usePage();
- const lastUsed = (page.props.auth as any)?.last_social_provider as string | undefined;
+ const lastUsed = (page.props.auth as any)?.last_social_provider as
+ | string
+ | undefined;
if (!route().has('auth.socialite.redirect') || !providers.length) {
return null;
@@ -25,9 +30,17 @@ export default function SocialiteProviders() {
{providers.map(({ name, icon: Icon }) => (
{lastUsed === name && (
diff --git a/resources/js/react/layouts/AuthCardLayout.tsx b/resources/js/react/layouts/AuthCardLayout.tsx
index 76d935e..bd6d703 100644
--- a/resources/js/react/layouts/AuthCardLayout.tsx
+++ b/resources/js/react/layouts/AuthCardLayout.tsx
@@ -1,7 +1,13 @@
import AlertMessage from '@/components/AlertMessage';
import AppLogo from '@/components/AppLogo';
import Footer from '@/components/Footer';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
import { Head, Link, usePage } from '@inertiajs/react';
import type { ReactNode } from 'react';
@@ -13,7 +19,13 @@ interface AuthCardLayoutProps {
outside?: ReactNode;
}
-export default function AuthCardLayout({ title, description, cardClass, children, outside }: AuthCardLayoutProps) {
+export default function AuthCardLayout({
+ title,
+ description,
+ cardClass,
+ children,
+ outside,
+}: AuthCardLayoutProps) {
const page = usePage();
const status = page.props.status as string | undefined;
const error = page.props.error as string | undefined;
diff --git a/resources/js/react/pages/ForgotPassword.tsx b/resources/js/react/pages/ForgotPassword.tsx
index 0fa3bf7..ef709de 100644
--- a/resources/js/react/pages/ForgotPassword.tsx
+++ b/resources/js/react/pages/ForgotPassword.tsx
@@ -25,7 +25,11 @@ export default function ForgotPassword() {
'Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.',
)}
>
-
- {errors.password && {errors.password}}
+ {errors.password && (
+
+ {errors.password}
+
+ )}
@@ -87,7 +112,9 @@ export default function Login() {
name="remember"
data-testid="remember-me"
checked={data.remember}
- onCheckedChange={(checked) => setData('remember', !!checked)}
+ onCheckedChange={(checked) =>
+ setData('remember', !!checked)
+ }
/>
{t('Remember-me')}
@@ -105,7 +132,12 @@ export default function Login() {
)}
-